From d6902b195c20ddd094765bb63432deb24df01d81 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 15 Oct 2026 18:19:38 -0400 Subject: [PATCH 1/3] Draw on the paused replay Coaches reviewing a match over a screen share pause on a moment and want to circle a player or show where someone should have gone. Until now the only way to draw was to capture the moment and open it in the editor. The match card gets a pen next to Capture (and the D key). Taking it out pauses playback and shows two tools, freehand and a straight arrow, plus Clear once something is drawn. Esc, D or the pen again put it away; the strokes stay until cleared or until the moment changes: playing clears them and puts the pen away, seeking or changing round clears them. Strokes are the editor's own drawing elements (FreeDrawing, Line with an arrowhead) in the attack-side world, drawn by the editor's DrawingPainter in the user's last editor pen colour and thickness, in a layer of its own above everything on the map. The layer is flipped on defense as the editor flips a page's drawings, zooms and pans with the map, and repaints only when the strokes change. While the pen is out a drag draws instead of panning; the wheel still zooms. The strokes live in the replay screen's own state, never in the editor's drawing providers. Capture saves them as the page's drawings, on both paths: locally as StrategyPage.drawingData, in the cloud as ordinary drawing elements through the existing page ops. No saved format, Hive model, export or server change. DrawingPainter loses a provider parameter it never read, so the replay can paint with it. Co-Authored-By: Claude Opus 5.5 --- lib/replay/replay_capture.dart | 25 +- lib/replay/replay_drawing.dart | 215 ++++++ lib/replay_view.dart | 26 +- lib/widgets/drawing_painter.dart | 3 - lib/widgets/replay/replay_canvas.dart | 46 +- lib/widgets/replay/replay_drawing_layer.dart | 98 +++ lib/widgets/replay/replay_match_card.dart | 121 +++- test/replay_capture_test.dart | 35 +- test/replay_drawing_test.dart | 658 +++++++++++++++++++ test/replay_test_support.dart | 39 ++ 10 files changed, 1210 insertions(+), 56 deletions(-) create mode 100644 lib/replay/replay_drawing.dart create mode 100644 lib/widgets/replay/replay_drawing_layer.dart create mode 100644 test/replay_drawing_test.dart diff --git a/lib/replay/replay_capture.dart b/lib/replay/replay_capture.dart index 51a54b76f..480ac9732 100644 --- a/lib/replay/replay_capture.dart +++ b/lib/replay/replay_capture.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/replay_capture_record.dart'; +import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; @@ -52,22 +53,24 @@ class ReplayCapture { /// The pages captured into [strategy], in order. List get pageIds => List.unmodifiable(_pageIds); - /// Saves [frame] as a page named [pageName] and returns the strategy. - /// Refuses, without writing, a frame holding a non-finite number. If the - /// user deleted the strategy or moved it to another map, this starts a - /// new one rather than writing this map's positions into it. + /// Saves [frame] as a page named [pageName], with [drawings] as its + /// drawings, and returns the strategy. Refuses, without writing, a frame + /// holding a non-finite number. If the user deleted the strategy or moved + /// it to another map, this starts a new one rather than writing this + /// map's positions into it. /// /// Captures are written one after another, in the order asked for. Future capture( ReplayFrame frame, { required String pageName, + List drawings = const [], }) { try { _checkFinite(frame); } catch (error, stackTrace) { return Future.error(error, stackTrace); } - final page = (name: pageName, frame: frame); + final page = (name: pageName, frame: frame, drawings: drawings); final written = _tail.then((_) => _write(page)); _tail = written.then((_) {}, onError: (_) {}); return written; @@ -133,8 +136,12 @@ class ReplayCapture { } } -/// One replay moment to save as a page. -typedef CapturedPage = ({String name, ReplayFrame frame}); +/// One replay moment to save as a page, with what the user drew over it. +typedef CapturedPage = ({ + String name, + ReplayFrame frame, + List drawings, +}); /// The strategy a replay's captures fill, as it stood when last read. typedef CapturedStrategy = ({ @@ -273,7 +280,7 @@ class LocalReplayCaptureStore implements ReplayCaptureStore { id: const Uuid().v4(), name: captured.name, isAutoNamed: false, - drawingData: const [], + drawingData: captured.drawings, agentData: frame.agents, abilityData: frame.abilities, textData: const [], @@ -389,7 +396,7 @@ class CloudReplayCaptureStore implements ReplayCaptureStore { id: pageId, name: page.name, isAutoNamed: false, - drawingData: const [], + drawingData: page.drawings, agentData: frame.agents, abilityData: frame.abilities, textData: const [], diff --git a/lib/replay/replay_drawing.dart b/lib/replay/replay_drawing.dart new file mode 100644 index 000000000..7d4152af2 --- /dev/null +++ b/lib/replay/replay_drawing.dart @@ -0,0 +1,215 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:icarus/const/bounding_box.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/action_history_models.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/replay/replay_playback.dart'; +import 'package:uuid/uuid.dart'; + +/// The two ways to draw over a replay. +enum ReplayDrawingTool { freehand, arrow } + +/// What the user draws over the paused replay. Strokes are the editor's own +/// drawing elements, in the same world coordinates as a page's, so they look +/// like editor drawings and a capture saves them as page drawings. +/// +/// They mark up the moment on screen, so they go when it does: playing +/// clears them and puts the pen away, and moving the clock while paused +/// clears them. Nothing is saved by drawing. +class ReplayDrawing extends ChangeNotifier { + ReplayDrawing(this.playback) : _timeMs = playback.timeMs { + playback.addListener(_playbackChanged); + } + + final ReplayPlayback playback; + + bool _active = false; + ReplayDrawingTool _tool = ReplayDrawingTool.freehand; + final List _strokes = []; + DrawingElement? _current; + int _revision = 0; + int _timeMs; + + /// Whether a drag on the map draws rather than pans. + bool get active => _active; + ReplayDrawingTool get tool => _tool; + + /// The finished strokes, oldest first. + List get strokes => List.unmodifiable(_strokes); + + /// The stroke under the pointer, until the drag ends. + DrawingElement? get current => _current; + + /// Changes whenever what is drawn changes, for the painter. + int get revision => _revision; + + /// Takes the pen out, pausing playback, or puts it away. Strokes stay + /// either way. + void toggle() => _active ? stop() : start(); + + void start() { + playback.pause(); + if (_active) return; + _active = true; + notifyListeners(); + } + + void stop() { + if (!_active) return; + _active = false; + _current = null; + _revision++; + notifyListeners(); + } + + set tool(ReplayDrawingTool value) { + if (_tool == value) return; + _tool = value; + notifyListeners(); + } + + /// Starts a stroke at [screenPoint], a point on the canvas in the + /// attack-side world [coordinates] describe, as the editor's painter does. + void begin( + Offset screenPoint, + CoordinateSystem coordinates, { + required Color color, + required double thickness, + }) { + if (!_active) return; + final point = coordinates.screenToCoordinate(screenPoint); + final id = const Uuid().v4(); + final box = BoundingBox(min: point, max: point); + _current = switch (_tool) { + ReplayDrawingTool.freehand => FreeDrawing( + colorValue: color.toARGB32(), + thickness: thickness, + boundingBox: box, + isDotted: false, + hasArrow: false, + id: id, + )..appendPoint(point), + ReplayDrawingTool.arrow => Line( + lineStart: point, + lineEnd: point, + colorValue: color.toARGB32(), + thickness: thickness, + boundingBox: box, + isDotted: false, + hasArrow: true, + id: id, + ), + }; + _changed(); + } + + /// Carries the stroke under the pointer on to [screenPoint]. + void extend(Offset screenPoint, CoordinateSystem coordinates) { + final point = coordinates.screenToCoordinate(screenPoint); + switch (_current) { + case final FreeDrawing stroke: + // The editor's spacing, so a stroke is as smooth here as there. + final minDistance = coordinates.normalize(Settings.freeDrawMinDistance); + if ((stroke.listOfPoints.last - point).distance < minDistance) return; + stroke + ..appendPoint(point) + ..boundingBox = _grown(stroke.boundingBox!, point) + ..rebuildPath(coordinates); + case final Line line: + line + ..updateEndPoint(point) + ..boundingBox = _grown( + BoundingBox(min: line.lineStart, max: line.lineStart), + point, + ); + default: + return; + } + _changed(); + } + + /// Ends the stroke under the pointer. A click that never moved draws + /// nothing. + void end(CoordinateSystem coordinates) { + final stroke = _current; + if (stroke == null) return; + _current = null; + switch (stroke) { + case final FreeDrawing drawn when drawn.listOfPoints.length > 1: + var finished = drawn; + if (Settings.enableStrokeSimplification) { + finished = + douglasPeucker(finished, Settings.strokeSimplificationEpsilon); + } + _strokes.add(finished + ..recomputeCachedPolylineLength() + ..rebuildPath(coordinates)); + case final Line line when line.lineEnd != line.lineStart: + _strokes.add(line); + default: + break; + } + _changed(); + } + + void clear() { + if (_strokes.isEmpty && _current == null) return; + _strokes.clear(); + _current = null; + _changed(); + } + + /// Builds every freehand path again for a canvas of a new size. Doesn't + /// notify: it runs while the layer that paints them builds. + void rebuildPaths(CoordinateSystem coordinates) { + for (final stroke in [..._strokes, if (_current != null) _current!]) { + if (stroke is FreeDrawing) stroke.rebuildPath(coordinates); + } + _revision++; + } + + /// Copies of the strokes for a captured page, sharing nothing with the + /// ones on screen. + List forCapture() => + [for (final stroke in _strokes) cloneDrawingElement(stroke)]; + + void _playbackChanged() { + final moved = playback.timeMs != _timeMs; + _timeMs = playback.timeMs; + if (playback.playing) { + if (!_active && _strokes.isEmpty && _current == null) return; + _active = false; + _strokes.clear(); + _current = null; + _changed(); + } else if (moved) { + clear(); + } + } + + void _changed() { + _revision++; + notifyListeners(); + } + + static BoundingBox _grown(BoundingBox box, Offset point) => BoundingBox( + min: Offset( + math.min(box.min.dx, point.dx), + math.min(box.min.dy, point.dy), + ), + max: Offset( + math.max(box.max.dx, point.dx), + math.max(box.max.dy, point.dy), + ), + ); + + @override + void dispose() { + playback.removeListener(_playbackChanged); + super.dispose(); + } +} diff --git a/lib/replay_view.dart b/lib/replay_view.dart index 35b978289..a8d3819bc 100644 --- a/lib/replay_view.dart +++ b/lib/replay_view.dart @@ -16,6 +16,7 @@ import 'package:icarus/const/utilities.dart'; import 'package:icarus/replay/replay_cone_cuts.dart'; import 'package:icarus/replay/replay_cone_worker.dart'; import 'package:icarus/replay/replay_decoder.dart'; +import 'package:icarus/replay/replay_drawing.dart'; import 'package:icarus/replay/replay_frame.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_files.dart'; @@ -62,6 +63,10 @@ class _ReplayViewState extends ConsumerState Timer? _progressTimer; ReplayPlayback? _playback; + + /// What the user draws over the paused replay. The screen's own, never + /// the editor's drawings. + ReplayDrawing? _drawing; ProviderSubscription>? _heightRuntime; SvgHeightVisibility? _heightModel; SvgHeightRuntime? _heightRuntimeValue; @@ -117,7 +122,10 @@ class _ReplayViewState extends ConsumerState perspective: perspective, )..addListener(_syncMapSide); if (_heightModel != null) playback.heightModel = _heightModel; - setState(() => _playback = playback); + setState(() { + _playback = playback; + _drawing = ReplayDrawing(playback); + }); unawaited(_startConeCuts()); claimEditorState(); _ticker.start(); @@ -248,6 +256,7 @@ class _ReplayViewState extends ConsumerState _loader.cancel(); _ticker.dispose(); _coneCuts?.dispose(); + _drawing?.dispose(); _playback ?..removeListener(_syncMapSide) ..dispose(); @@ -256,8 +265,16 @@ class _ReplayViewState extends ConsumerState KeyEventResult _onKey(FocusNode node, KeyEvent event) { final playback = _playback; - if (playback == null || event is KeyUpEvent) return KeyEventResult.ignored; + final drawing = _drawing; + if (playback == null || drawing == null || event is KeyUpEvent) { + return KeyEventResult.ignored; + } switch (event.logicalKey) { + case LogicalKeyboardKey.keyD: + if (event is KeyRepeatEvent) return KeyEventResult.handled; + drawing.toggle(); + case LogicalKeyboardKey.escape when drawing.active: + drawing.stop(); case LogicalKeyboardKey.space: if (event is KeyRepeatEvent) return KeyEventResult.handled; playback.togglePlaying(); @@ -290,6 +307,7 @@ class _ReplayViewState extends ConsumerState child: playback != null ? _ReplayBench( playback: playback, + drawing: _drawing!, map: map!, onReturnFromEditor: claimEditorState, ) @@ -371,11 +389,13 @@ String replayMapName(MapValue map) { class _ReplayBench extends StatelessWidget { const _ReplayBench({ required this.playback, + required this.drawing, required this.map, required this.onReturnFromEditor, }); final ReplayPlayback playback; + final ReplayDrawing drawing; final MapValue map; final VoidCallback onReturnFromEditor; @@ -387,6 +407,7 @@ class _ReplayBench extends StatelessWidget { child: RepaintBoundary( child: ReplayCanvas( playback: playback, + drawing: drawing, map: map, reservedRight: ReplayRoster.width + 16, ), @@ -398,6 +419,7 @@ class _ReplayBench extends StatelessWidget { padding: const EdgeInsets.all(8), child: ReplayMatchCard( playback: playback, + drawing: drawing, map: map, onReturnFromEditor: onReturnFromEditor, ), diff --git a/lib/widgets/drawing_painter.dart b/lib/widgets/drawing_painter.dart index bdcc65757..d7d5f53d7 100644 --- a/lib/widgets/drawing_painter.dart +++ b/lib/widgets/drawing_painter.dart @@ -69,7 +69,6 @@ class _InteractivePainterState extends ConsumerState { updateCounter: drawingState.updateCounter, coordinateSystem: coordinateSystem, elements: drawingState.elements, // Pass the data directly - drawingProvider: ref.read(drawingProvider.notifier), currentLine: drawingState.currentElement); final currentInteractionState = ref.watch(interactionStateProvider); @@ -524,14 +523,12 @@ class DrawingPainter extends CustomPainter { final List elements; // Store the drawing elements final DrawingElement? currentLine; final int updateCounter; - final DrawingProvider drawingProvider; DrawingPainter({ required this.updateCounter, required this.currentLine, required this.coordinateSystem, required this.elements, - required this.drawingProvider, }); @override diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart index 64b4b4119..9a0a2ae69 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -8,6 +8,7 @@ import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/view_cone/cone_coverage.dart'; +import 'package:icarus/replay/replay_drawing.dart'; import 'package:icarus/replay/replay_frame.dart'; import 'package:icarus/replay/replay_playback.dart'; import 'package:icarus/widgets/canonical_map_artwork.dart'; @@ -17,21 +18,25 @@ import 'package:icarus/widgets/map_svg_color_mapper.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; import 'package:icarus/widgets/page_transition_overlay.dart'; import 'package:icarus/widgets/replay/replay_cones.dart'; +import 'package:icarus/widgets/replay/replay_drawing_layer.dart'; import 'package:icarus/widgets/replay/replay_effects.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -/// The map as the editor draws it, with the replay's current moment on top. -/// Pans and zooms like the editor; [reservedRight] keeps the map clear of the -/// roster panel. +/// The map as the editor draws it, with the replay's current moment on top +/// and what the user drew over it above that. Pans and zooms like the +/// editor, except that while the pen is out a drag draws instead of +/// panning; [reservedRight] keeps the map clear of the roster panel. class ReplayCanvas extends ConsumerStatefulWidget { const ReplayCanvas({ super.key, required this.playback, + required this.drawing, required this.map, required this.reservedRight, }); final ReplayPlayback playback; + final ReplayDrawing drawing; final MapValue map; final double reservedRight; @@ -75,8 +80,32 @@ class _ReplayCanvasState extends ConsumerState { /// it failed), painted together as in the editor. final _cones = ConeCoverage(); + /// Whether a drag pans, as it does unless the pen is out. + late bool _panEnabled = !widget.drawing.active; + + @override + void initState() { + super.initState(); + widget.drawing.addListener(_drawingChanged); + } + + @override + void didUpdateWidget(ReplayCanvas oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.drawing == widget.drawing) return; + oldWidget.drawing.removeListener(_drawingChanged); + widget.drawing.addListener(_drawingChanged); + _panEnabled = !widget.drawing.active; + } + + void _drawingChanged() { + if (_panEnabled == !widget.drawing.active) return; + setState(() => _panEnabled = !widget.drawing.active); + } + @override void dispose() { + widget.drawing.removeListener(_drawingChanged); _controller.dispose(); _cones.dispose(); super.dispose(); @@ -131,13 +160,20 @@ class _ReplayCanvasState extends ConsumerState { alignment: Alignment.topLeft, minScale: 1, maxScale: 8, + panEnabled: _panEnabled, boundaryMargin: const EdgeInsets.all(double.infinity), child: SizedBox( width: worldWidth, height: height, child: ListenableBuilder( listenable: widget.playback, - builder: (context, _) { + // Built once here, not every frame: it repaints only when + // the strokes change. + child: ReplayDrawingLayer( + drawing: widget.drawing, + playback: widget.playback, + ), + builder: (context, strokes) { final frame = widget.playback.frame; final isAttack = frame.isAttack; final mapName = Maps.mapNames[widget.map]; @@ -316,6 +352,8 @@ class _ReplayCanvasState extends ConsumerState { ), ), ], + // What the user drew, above everything on the map. + Positioned.fill(child: strokes!), ], ); }, diff --git a/lib/widgets/replay/replay_drawing_layer.dart b/lib/widgets/replay/replay_drawing_layer.dart new file mode 100644 index 000000000..27080b1aa --- /dev/null +++ b/lib/widgets/replay/replay_drawing_layer.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/main.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/replay/replay_drawing.dart'; +import 'package:icarus/replay/replay_playback.dart'; +import 'package:icarus/widgets/drawing_painter.dart'; +import 'package:icarus/widgets/replay/replay_select.dart'; + +/// What the user drew over the replay, painted by the editor's own drawing +/// painter. While the pen is out a drag here draws; otherwise every pointer +/// passes through to the map. +/// +/// The layer sits in the canvas's world, so strokes zoom and pan with the +/// map. Strokes are stored in the attack-side world like everything placed, +/// and the layer is flipped on defense, as the editor flips a page's +/// drawings. It repaints only when the strokes change. +class ReplayDrawingLayer extends ConsumerStatefulWidget { + const ReplayDrawingLayer({ + super.key, + required this.drawing, + required this.playback, + }); + + final ReplayDrawing drawing; + final ReplayPlayback playback; + + @override + ConsumerState createState() => _ReplayDrawingLayerState(); +} + +class _ReplayDrawingLayerState extends ConsumerState { + /// The canvas size the freehand paths were last built for. + Size? _pathsSize; + + @override + Widget build(BuildContext context) { + final drawing = widget.drawing; + // The pen the editor would start with: the user's last colour and + // thickness there. + final preferences = ref.watch(appPreferencesProvider); + final color = Color(preferences.drawingColorValue); + final thickness = + Settings.strokeThicknessOptions.contains(preferences.drawingThickness) + ? preferences.drawingThickness + : Settings.defaultStrokeThickness; + return ReplaySelect( + listenable: widget.playback, + select: () => widget.playback.frame.isAttack, + builder: (context, isAttack) => ListenableBuilder( + listenable: drawing, + builder: (context, _) { + final coordinates = CoordinateSystem.instance; + if (_pathsSize != coordinates.effectiveSize) { + _pathsSize = coordinates.effectiveSize; + drawing.rebuildPaths(coordinates); + } + return RepaintBoundary( + child: Transform.flip( + flipX: !isAttack, + flipY: !isAttack, + child: IgnorePointer( + ignoring: !drawing.active, + child: MouseRegion( + cursor: staticDrawingCursor ?? SystemMouseCursors.precise, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: (details) => drawing.begin( + details.localPosition, + coordinates, + color: color, + thickness: thickness, + ), + onPanUpdate: (details) => + drawing.extend(details.localPosition, coordinates), + onPanEnd: (_) => drawing.end(coordinates), + onPanCancel: () => drawing.end(coordinates), + child: CustomPaint( + size: Size.infinite, + painter: DrawingPainter( + updateCounter: drawing.revision, + coordinateSystem: coordinates, + elements: drawing.strokes, + currentLine: drawing.current, + ), + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/replay/replay_match_card.dart b/lib/widgets/replay/replay_match_card.dart index 83639e693..61c761390 100644 --- a/lib/widgets/replay/replay_match_card.dart +++ b/lib/widgets/replay/replay_match_card.dart @@ -10,6 +10,7 @@ import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:icarus/providers/collab/replay_capture_queue_provider.dart'; import 'package:icarus/replay/replay_capture.dart'; import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_drawing.dart'; import 'package:icarus/replay/replay_playback.dart'; import 'package:icarus/replay_view.dart'; import 'package:icarus/services/app_error_reporter.dart'; @@ -18,17 +19,19 @@ import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/replay/replay_select.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -/// Where the match stands, and the one thing to do with it: capture the -/// moment into a strategy. +/// Where the match stands, and what to do with the moment on screen: draw +/// over it, or capture it into a strategy. class ReplayMatchCard extends ConsumerStatefulWidget { const ReplayMatchCard({ super.key, required this.playback, + required this.drawing, required this.map, required this.onReturnFromEditor, }); final ReplayPlayback playback; + final ReplayDrawing drawing; final MapValue map; /// Called after the captured strategy's editor closes, which leaves its @@ -67,8 +70,11 @@ class _ReplayMatchCardState extends ConsumerState { playback.timeMs, perspective: playback.perspective, ); - final strategy = - await _capture.capture(frame, pageName: replayMomentLabel(playback)); + final strategy = await _capture.capture( + frame, + pageName: replayMomentLabel(playback), + drawings: widget.drawing.forCapture(), + ); _strategyIds.add(strategy.id); } catch (error, stackTrace) { AppErrorReporter.reportError( @@ -297,6 +303,17 @@ class _ReplayMatchCardState extends ConsumerState { ), ), const SizedBox(width: 8), + ReplaySelect( + listenable: widget.drawing, + select: () => widget.drawing.active, + builder: (context, active) => _ToolButton( + icon: LucideIcons.pencil, + tooltip: active ? 'Stop drawing (D)' : 'Draw (D)', + selected: active, + onPressed: widget.drawing.toggle, + ), + ), + const SizedBox(width: 8), ShadTooltip( builder: (context) => const Text("See the other team's side"), @@ -310,6 +327,7 @@ class _ReplayMatchCardState extends ConsumerState { ), ], ), + _DrawingTools(drawing: widget.drawing), if (captured > 0) ...[ const SizedBox(height: 8), Row( @@ -474,6 +492,101 @@ class _CloudStatus extends StatelessWidget { }; } +/// The pen's tools, under the card's actions while the pen is out: draw +/// freehand or a straight arrow, and clear what is drawn. +class _DrawingTools extends StatelessWidget { + const _DrawingTools({required this.drawing}); + + final ReplayDrawing drawing; + + @override + Widget build(BuildContext context) { + return ReplaySelect( + listenable: drawing, + select: () => (drawing.active, drawing.tool, drawing.strokes.isNotEmpty), + builder: (context, shown) { + final (active, tool, drawn) = shown; + if (!active) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + _ToolButton( + icon: LucideIcons.lineSquiggle, + tooltip: 'Free draw', + selected: tool == ReplayDrawingTool.freehand, + onPressed: () => drawing.tool = ReplayDrawingTool.freehand, + ), + const SizedBox(width: 8), + _ToolButton( + icon: LucideIcons.moveUpRight, + tooltip: 'Arrow', + selected: tool == ReplayDrawingTool.arrow, + onPressed: () => drawing.tool = ReplayDrawingTool.arrow, + ), + const Spacer(), + if (drawn) + ShadButton.link( + height: 24, + padding: EdgeInsets.zero, + onPressed: drawing.clear, + child: const Text('Clear'), + ), + ], + ), + ); + }, + ); + } +} + +/// A tool in the card: outlined at rest, a raised violet surface while it +/// is on, like the editor's checked tools. +class _ToolButton extends StatelessWidget { + const _ToolButton({ + required this.icon, + required this.tooltip, + required this.selected, + required this.onPressed, + }); + + final IconData icon; + final String tooltip; + final bool selected; + final VoidCallback onPressed; + + /// The Shad buttons' own corner radius. + static const double _radius = 6; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + final glyph = Icon(icon, size: 16); + return ShadTooltip( + builder: (context) => Text(tooltip), + child: selected + ? DecoratedBox( + decoration: Settings.raisedPrimary(_radius), + child: ShadIconButton.ghost( + width: 32, + height: 32, + foregroundColor: theme.primaryForeground, + hoverForegroundColor: theme.primaryForeground, + hoverBackgroundColor: Colors.transparent, + onPressed: onPressed, + icon: glyph, + ), + ) + : ShadIconButton.outline( + width: 32, + height: 32, + onPressed: onPressed, + icon: glyph, + ), + ); + } +} + class _Score extends StatelessWidget { const _Score({required this.ally, required this.enemy}); diff --git a/test/replay_capture_test.dart b/test/replay_capture_test.dart index b99d6001c..bf40bf7df 100644 --- a/test/replay_capture_test.dart +++ b/test/replay_capture_test.dart @@ -8,7 +8,6 @@ import 'package:icarus/const/agents.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; -import 'package:icarus/const/settings.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/providers/auth_provider.dart'; @@ -19,45 +18,13 @@ import 'package:icarus/providers/collab/replay_capture_queue_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/collab/cloud_library_models.dart'; import 'package:icarus/providers/library_workspace_provider.dart'; -import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/replay/replay_capture.dart'; import 'package:icarus/replay/replay_frame.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; -import 'package:uuid/uuid.dart'; -/// What the library's New Strategy makes: one blank page. -Future createBlankStrategy(MapValue map, String name) async { - final strategy = StrategyData( - id: const Uuid().v4(), - name: name, - mapData: map, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - folderID: null, - pages: [ - StrategyPage( - id: const Uuid().v4(), - name: 'Page 1', - isAutoNamed: true, - drawingData: const [], - agentData: const [], - abilityData: const [], - textData: const [], - imageData: const [], - utilityData: const [], - sortIndex: 0, - isAttack: true, - settings: StrategySettings(), - ), - ], - ); - await Hive.box(HiveBoxNames.strategiesBox) - .put(strategy.id, strategy); - return strategy; -} +import 'replay_test_support.dart'; ReplayFrame frame({Offset position = const Offset(500, 500)}) => ReplayFrame( timeMs: 1000, diff --git a/test/replay_drawing_test.dart b/test/replay_drawing_test.dart new file mode 100644 index 000000000..bc0b17d38 --- /dev/null +++ b/test/replay_drawing_test.dart @@ -0,0 +1,658 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/replay_capture_record.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/collab/replay_capture_queue_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/replay_library_provider.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/svg_height_runtime_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/replay/replay_capture.dart'; +import 'package:icarus/replay/replay_decoder.dart'; +import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_drawing.dart'; +import 'package:icarus/replay/replay_files.dart'; +import 'package:icarus/replay/replay_frame.dart'; +import 'package:icarus/replay/replay_map_projection.dart'; +import 'package:icarus/replay/replay_playback.dart'; +import 'package:icarus/replay_view.dart'; +import 'package:icarus/widgets/drawing_painter.dart'; +import 'package:icarus/widgets/replay/replay_canvas.dart'; +import 'package:icarus/widgets/replay/replay_drawing_layer.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'replay_test_support.dart'; + +const _mapPath = '/Game/Maps/Juliett/Juliett'; + +ReplayPlayback _playback() => ReplayPlayback( + document: ReplayDocument.fromBytes(icrp(sampleJson())), + projection: ReplayMapProjection.forMapPath(_mapPath)!, + perspective: ReplayTeam.red, + ); + +/// Serves one decoded replay from the cache, so opening it needs no +/// native decoder. +class _CachedReplay extends ReplayFiles { + _CachedReplay(this.bytes, Directory support) + : super(supportDirectory: () async => support); + + final Uint8List bytes; + + @override + Future readCache(ReplayFile file, String decoderVersion) async => + bytes; + + @override + Future> localSubjects() async => const {}; +} + +/// A capture queue with nothing in it that never sends. +class _IdleQueue extends ReplayCaptureQueue { + @override + ReplayCaptureQueueState build() => const ReplayCaptureQueueState(); +} + +void main() { + late Directory temp; + late Box box; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + temp = await Directory.systemTemp.createTemp('icarus-replay-drawing'); + Hive.init(temp.path); + registerIcarusAdapters(Hive); + // The canvas draws in the user's default map theme. + await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); + await Hive.openBox(HiveBoxNames.appPreferencesBox); + await _loadFonts(); + }); + + setUp(() async { + box = await Hive.openBox(HiveBoxNames.strategiesBox); + await box.clear(); + }); + + tearDownAll(() async { + await Hive.close(); + await temp.delete(recursive: true); + }); + + group('drawing over the replay', () { + final coordinates = CoordinateSystem(playAreaSize: const Size(1600, 900)); + + /// Draws a freehand stroke across the canvas. + void stroke(ReplayDrawing drawing) { + drawing.begin( + const Offset(100, 100), + coordinates, + color: Colors.white, + thickness: 5, + ); + for (var x = 120.0; x <= 300; x += 20) { + drawing.extend(Offset(x, 100), coordinates); + } + drawing.end(coordinates); + } + + test('taking the pen out pauses playback', () { + final playback = _playback()..play(); + final drawing = ReplayDrawing(playback)..toggle(); + expect(drawing.active, isTrue); + expect(playback.playing, isFalse); + drawing.toggle(); + expect(drawing.active, isFalse); + }); + + test('playing clears the strokes and puts the pen away', () { + final playback = _playback(); + final drawing = ReplayDrawing(playback)..start(); + stroke(drawing); + expect(drawing.strokes, hasLength(1)); + playback.play(); + expect(drawing.strokes, isEmpty); + expect(drawing.active, isFalse); + }); + + test('moving the clock clears the strokes; the pen stays out', () { + final playback = _playback(); + final drawing = ReplayDrawing(playback)..start(); + stroke(drawing); + playback.seek(playback.timeMs + 5000); + expect(drawing.strokes, isEmpty); + expect(drawing.active, isTrue); + + stroke(drawing); + playback.nextRound(); + expect(drawing.strokes, isEmpty); + }); + + test('anything else the replay changes leaves the strokes alone', () { + final playback = _playback(); + final drawing = ReplayDrawing(playback)..start(); + stroke(drawing); + playback + ..perspective = ReplayTeam.blue + ..speed = 2 + ..coneCutArrived(); + expect(drawing.strokes, hasLength(1)); + drawing.stop(); + expect(drawing.strokes, hasLength(1), reason: 'the pen put away'); + }); + + test('a click that never moves draws nothing', () { + final drawing = ReplayDrawing(_playback())..start(); + for (final tool in ReplayDrawingTool.values) { + drawing + ..tool = tool + ..begin( + const Offset(50, 50), + coordinates, + color: Colors.white, + thickness: 5, + ) + ..end(coordinates); + } + expect(drawing.strokes, isEmpty); + }); + + test('the arrow is a straight line with an arrowhead', () { + final drawing = ReplayDrawing(_playback()) + ..start() + ..tool = ReplayDrawingTool.arrow + ..begin( + const Offset(100, 100), + coordinates, + color: Colors.white, + thickness: 5, + ) + ..extend(const Offset(150, 200), coordinates) + ..extend(const Offset(400, 300), coordinates) + ..end(coordinates); + final line = drawing.strokes.single as Line; + expect(line.hasArrow, isTrue); + expect(line.lineStart, + coordinates.screenToCoordinate(const Offset(100, 100))); + expect( + line.lineEnd, coordinates.screenToCoordinate(const Offset(400, 300))); + }); + + test('a capture copies the strokes, sharing nothing with the screen', () { + final drawing = ReplayDrawing(_playback())..start(); + stroke(drawing); + final copy = drawing.forCapture().single as FreeDrawing; + final shown = drawing.strokes.single as FreeDrawing; + expect(copy.listOfPoints, shown.listOfPoints); + expect(identical(copy, shown), isFalse); + expect(identical(copy.listOfPoints, shown.listOfPoints), isFalse); + }); + }); + + group('the replay screen', () { + const viewport = Size(1600, 900); + + Future openReplay(WidgetTester tester) async { + tester.view + ..physicalSize = viewport + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final files = _CachedReplay(icrp(sampleJson()), temp); + await tester.pumpWidget(ProviderScope( + overrides: [ + replayFilesProvider.overrideWithValue(files), + svgHeightRuntimeProvider.overrideWith((ref, map) async => null), + replayCaptureQueueProvider.overrideWith(_IdleQueue.new), + replayCaptureStoreProvider.overrideWithValue( + () => LocalReplayCaptureStore(createBlankStrategy), + ), + ], + child: ShadApp( + home: ReplayView( + file: ReplayFile( + path: '${temp.path}/match.vrf', + source: ReplaySource.icarus, + sizeBytes: 0, + modified: DateTime(2026), + ), + probe: const ReplayProbe( + matchId: 'match', + mapPath: _mapPath, + build: 'test', + durationMs: 100000, + supported: true, + decoderVersion: 'test', + agentIds: [], + ), + ), + ), + )); + // The document is read on another isolate. + for (var i = 0; + i < 50 && find.byType(ReplayCanvas).evaluate().isEmpty; + i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + } + expect(find.byType(ReplayCanvas), findsOneWidget); + await tester.pump(const Duration(milliseconds: 16)); + } + + ReplayCanvas canvas(WidgetTester tester) => + tester.widget(find.byType(ReplayCanvas)); + + Matrix4 view(WidgetTester tester) => tester + .widget(find.byType(InteractiveViewer)) + .transformationController! + .value + .clone(); + + /// Drags with the mouse across the middle of the map. + Future drag(WidgetTester tester) async { + final from = tester.getCenter(find.byType(ReplayDrawingLayer)); + final gesture = await tester.startGesture( + from, + kind: PointerDeviceKind.mouse, + ); + for (var i = 0; i < 10; i++) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + } + await gesture.up(); + await tester.pump(); + } + + testWidgets( + 'D and the pen button take the pen out and pause; Esc and D ' + 'put it away', (tester) async { + await openReplay(tester); + final playback = canvas(tester).playback; + final drawing = canvas(tester).drawing; + + await tester.sendKeyEvent(LogicalKeyboardKey.space); + expect(playback.playing, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.keyD); + await tester.pump(); + expect(drawing.active, isTrue); + expect(playback.playing, isFalse); + // The pen's tools show while it is out; Clear only once something is + // drawn. + expect(find.byIcon(LucideIcons.lineSquiggle), findsOneWidget); + expect(find.byIcon(LucideIcons.moveUpRight), findsOneWidget); + expect(find.text('Clear'), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(drawing.active, isFalse); + expect(find.byIcon(LucideIcons.lineSquiggle), findsNothing); + + await tester.tap(find.byIcon(LucideIcons.pencil)); + await tester.pump(); + expect(drawing.active, isTrue); + await tester.tap(find.byIcon(LucideIcons.pencil)); + await tester.pump(); + expect(drawing.active, isFalse); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyD); + expect(drawing.active, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.keyD); + expect(drawing.active, isFalse); + // Esc with the pen away is left to whoever else wants it. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + expect(drawing.active, isFalse); + await tester.pump(const Duration(milliseconds: 16)); + }); + + testWidgets( + 'with the pen out a drag draws and does not pan; the wheel ' + 'still zooms', (tester) async { + await openReplay(tester); + final drawing = canvas(tester).drawing; + + // Without the pen, the same drag pans the map. + final resting = view(tester); + await drag(tester); + expect(view(tester), isNot(resting)); + expect(drawing.strokes, isEmpty); + + drawing.start(); + await tester.pump(); + final before = view(tester); + await drag(tester); + expect(view(tester), before, reason: 'a drag draws instead of panning'); + final stroke = drawing.strokes.single as FreeDrawing; + expect(stroke.listOfPoints.length, greaterThan(5)); + expect(find.text('Clear'), findsOneWidget); + + final pointer = TestPointer(1, PointerDeviceKind.mouse); + final center = tester.getCenter(find.byType(ReplayDrawingLayer)); + await tester.sendEventToBinding(pointer.hover(center)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, -200))); + await tester.pump(); + expect(view(tester).getMaxScaleOnAxis(), + greaterThan(before.getMaxScaleOnAxis())); + expect(drawing.strokes, hasLength(1)); + + await tester.tap(find.text('Clear')); + await tester.pump(); + expect(drawing.strokes, isEmpty); + expect(find.text('Clear'), findsNothing); + }); + + testWidgets('strokes clear when playback resumes or the time changes', + (tester) async { + await openReplay(tester); + final drawing = canvas(tester).drawing; + + drawing.start(); + await tester.pump(); + await drag(tester); + expect(drawing.strokes, hasLength(1)); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(drawing.strokes, isEmpty); + expect(drawing.active, isTrue); + + await drag(tester); + expect(drawing.strokes, hasLength(1)); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(const Duration(milliseconds: 16)); + expect(drawing.strokes, isEmpty); + expect(drawing.active, isFalse); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(const Duration(milliseconds: 16)); + }); + + for (final defense in [false, true]) { + final side = defense ? 'defense' : 'attack'; + testWidgets( + 'on $side, a capture saves the strokes as page drawings the ' + 'editor draws where they were drawn', (tester) async { + await openReplay(tester); + final playback = canvas(tester).playback; + final drawing = canvas(tester).drawing; + if (defense) playback.perspective = ReplayTeam.blue; + await tester.pump(); + expect(playback.frame.isAttack, !defense); + + drawing.start(); + await tester.pump(); + final layer = find.byType(ReplayDrawingLayer); + final world = tester.getRect(layer); + final from = world.center - const Offset(100, 0); + final gesture = + await tester.startGesture(from, kind: PointerDeviceKind.mouse); + for (var i = 0; i < 10; i++) { + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + } + await gesture.up(); + await tester.pump(); + // A point along the stroke, where the user drew it, as a fraction + // of the world. + final drawn = from + const Offset(100, 0); + final fraction = Offset( + (drawn.dx - world.left) / world.width, + (drawn.dy - world.top) / world.height, + ); + + // On screen, the stroke is drawn there, over everything. + final shown = await _pixel( + tester, + find + .ancestor( + of: find.byType(ReplayCanvas), + matching: find.byType(RepaintBoundary), + ) + .first, + drawn, + ); + expect(shown, Colors.white); + + await tester.runAsync(() async { + await tester.tap(find.text('Capture')); + await Future.delayed(const Duration(milliseconds: 100)); + }); + // The write runs on Hive's real clock: the blank page the new + // strategy starts with is replaced by the capture once it lands. + bool captured() => + box.values.singleOrNull?.pages.single.name != 'Page 1' && + box.isNotEmpty; + for (var i = 0; i < 50 && !captured(); i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + } + final page = box.values.single.pages.single; + expect(page.isAttack, !defense); + final saved = page.drawingData.single as FreeDrawing; + final onScreen = drawing.strokes.single as FreeDrawing; + expect(saved.listOfPoints, onScreen.listOfPoints); + expect(identical(saved, onScreen), isFalse); + // Stored in the attack-side world, like everything placed. + final coordinates = CoordinateSystem.instance; + final expected = coordinates.screenToCoordinate(Offset( + (defense ? 1 - fraction.dx : fraction.dx) * world.width, + (defense ? 1 - fraction.dy : fraction.dy) * world.height, + )); + expect( + saved.listOfPoints.any((point) => (point - expected).distance < 0.01), + isTrue, + ); + + // The editor, at another size, draws the page's drawings at the + // same place in its world. + await tester.pumpWidget(const SizedBox()); + await _expectEditorDraws( + tester, + page, + at: fraction, + ); + }); + } + }); + + group('cloud capture', () { + test('carries the strokes in the page payload, unchanged', () async { + await Hive.openBox(HiveBoxNames.replayCapturesBox); + final container = ProviderContainer(overrides: [ + replayCaptureQueueProvider.overrideWith(_HeldQueue.new), + ]); + addTearDown(container.dispose); + final coordinates = CoordinateSystem(playAreaSize: const Size(1600, 900)); + final drawing = ReplayDrawing(_playback())..start(); + drawing.begin( + const Offset(100, 100), + coordinates, + color: Colors.white, + thickness: 5, + ); + for (var x = 120.0; x <= 300; x += 20) { + drawing.extend(Offset(x, 140), coordinates); + } + drawing + ..end(coordinates) + ..tool = ReplayDrawingTool.arrow + ..begin( + const Offset(400, 400), + coordinates, + color: Colors.white, + thickness: 5, + ) + ..extend(const Offset(500, 300), coordinates) + ..end(coordinates); + + final store = CloudReplayCaptureStore( + queue: container.read(replayCaptureQueueProvider.notifier), + queueState: () => container.read(replayCaptureQueueProvider), + accountId: 'account-1', + folderId: null, + settings: StrategySettings(), + themeProfileId: null, + ); + await ReplayCapture( + newStore: () => store, + map: MapValue.sunset, + name: 'r', + ).capture( + const ReplayFrame( + timeMs: 0, + round: null, + isAttack: false, + agents: [], + abilities: [], + utilities: [], + ), + pageName: 'one', + drawings: drawing.forCapture(), + ); + + // As the outbox keeps it across a restart. + final record = ReplayCaptureRecord.decode( + container.read(replayCaptureQueueProvider).captures.single.encode(), + ); + final payloads = [ + for (final op in record.items) + if (op is ElementAddOp && + cloudPayloadData(op.payload)['elementType'] == 'drawing') + op, + ]; + expect(payloads, hasLength(2)); + // Read back as a cloud page reads its drawings. + final read = [ + for (final op in payloads) + DrawingProvider.fromJson( + jsonEncode([cloudPayloadData(op.payload)]), + ).single, + ]; + final free = read.whereType().single; + final line = read.whereType().single; + final drawn = drawing.strokes; + expect(free.listOfPoints, (drawn[0] as FreeDrawing).listOfPoints); + expect(line.lineStart, (drawn[1] as Line).lineStart); + expect(line.lineEnd, (drawn[1] as Line).lineEnd); + expect(line.hasArrow, isTrue); + for (final op in payloads) { + expect(op.pagePublicId, record.pageId); + expect(op.elementPublicId, isNot(anyOf(drawn[0].id, drawn[1].id)), + reason: 'cloud items get fresh ids'); + } + }); + }); +} + +/// The app's fonts, so the screen lays out as it does in the app rather +/// than in the test font's wide boxes. +Future _loadFonts() async { + final manifest = + jsonDecode(await rootBundle.loadString('FontManifest.json')) as List; + for (final family in manifest.cast>()) { + final loader = FontLoader(family['family'] as String); + for (final font in (family['fonts'] as List).cast>()) { + loader.addFont(rootBundle.load(font['asset'] as String)); + } + await loader.load(); + } +} + +/// A capture queue that keeps what it is given and never sends. +class _HeldQueue extends ReplayCaptureQueue { + final _held = []; + + @override + ReplayCaptureQueueState build() => ReplayCaptureQueueState(captures: _held); + + @override + Future add(ReplayCaptureRecord capture) async { + _held.add(capture); + state = ReplayCaptureQueueState(captures: [..._held]); + } +} + +/// The colour [finder]'s picture has at the global point [at]. +Future _pixel(WidgetTester tester, Finder finder, Offset at) async { + final boundary = tester.renderObject(finder); + final origin = boundary.localToGlobal(Offset.zero); + final bytes = await tester.runAsync(() async { + final image = await boundary.toImage(); + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final width = image.width; + image.dispose(); + return (data!, width); + }); + final (data, width) = bytes!; + final x = (at.dx - origin.dx).round(); + final y = (at.dy - origin.dy).round(); + final i = (y * width + x) * 4; + return Color.fromARGB( + data.getUint8(i + 3), + data.getUint8(i), + data.getUint8(i + 1), + data.getUint8(i + 2), + ); +} + +/// Opens [page]'s drawings the way the editor's canvas does (its drawing +/// layer, flipped on defense) in a world of another size, and checks a +/// stroke is drawn at [at], a fraction of the world, and nothing far off. +Future _expectEditorDraws( + WidgetTester tester, + StrategyPage page, { + required Offset at, +}) async { + const world = Size(1280, 720); + final coordinates = CoordinateSystem(playAreaSize: world); + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(drawingProvider.notifier).fromHive(page.drawingData); + final boundary = GlobalKey(); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: Directionality( + textDirection: TextDirection.ltr, + child: Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: boundary, + child: SizedBox.fromSize( + size: world, + child: Transform.flip( + flipX: !page.isAttack, + flipY: !page.isAttack, + child: const InteractivePainter(), + ), + ), + ), + ), + ), + )); + await tester.pump(); + expect(CoordinateSystem.instance.effectiveSize, coordinates.effectiveSize); + final point = Offset(at.dx * world.width, at.dy * world.height); + final finder = find.byKey(boundary); + expect(await _pixel(tester, finder, point), Colors.white); + expect( + (await _pixel(tester, finder, point + const Offset(0, 40))).a, + 0, + reason: 'nothing drawn away from the stroke', + ); +} diff --git a/test/replay_test_support.dart b/test/replay_test_support.dart index 0fbd13938..d85b19bcc 100644 --- a/test/replay_test_support.dart +++ b/test/replay_test_support.dart @@ -1,7 +1,15 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/replay/replay_document.dart'; +import 'package:uuid/uuid.dart'; /// Builds an `.icrp` buffer the way `native/replay` lays it out. Uint8List icrp(Map json, @@ -86,3 +94,34 @@ Map sampleJson({Map? movement}) => { 'quality': {'transformVerified': true, 'decodeErrors': 0}, }; + +/// What the library's New Strategy makes: one blank page. +Future createBlankStrategy(MapValue map, String name) async { + final strategy = StrategyData( + id: const Uuid().v4(), + name: name, + mapData: map, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + folderID: null, + pages: [ + StrategyPage( + id: const Uuid().v4(), + name: 'Page 1', + isAutoNamed: true, + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: const [], + imageData: const [], + utilityData: const [], + sortIndex: 0, + isAttack: true, + settings: StrategySettings(), + ), + ], + ); + await Hive.box(HiveBoxNames.strategiesBox) + .put(strategy.id, strategy); + return strategy; +} From 42e236718101ca9e12663e1e9fa8580ef2da4096 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 15 Oct 2026 18:21:46 -0400 Subject: [PATCH 2/3] Take the pen out with the editor's Draw key D is the editor's Next page; Draw is Q unless the user rebound it. The replay's pen now answers to that same binding, and its tooltip names it. Co-Authored-By: Claude Opus 5.5 --- lib/replay_view.dart | 14 +++++++++++--- lib/widgets/replay/replay_match_card.dart | 10 +++++++++- test/replay_drawing_test.dart | 11 +++++++---- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/lib/replay_view.dart b/lib/replay_view.dart index a8d3819bc..d47db07cb 100644 --- a/lib/replay_view.dart +++ b/lib/replay_view.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/replay_library_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; @@ -269,10 +270,17 @@ class _ReplayViewState extends ConsumerState if (playback == null || drawing == null || event is KeyUpEvent) { return KeyEventResult.ignored; } + // The pen answers to the editor's Draw key, whatever the user bound it + // to, so the habit carries over. + final draw = ShortcutInfo.effectiveBindingFor( + IcarusShortcutAction.draw.name, + ref.read(appPreferencesProvider).customShortcutBindings, + ).toActivator(); + if (draw.accepts(event, HardwareKeyboard.instance)) { + if (event is! KeyRepeatEvent) drawing.toggle(); + return KeyEventResult.handled; + } switch (event.logicalKey) { - case LogicalKeyboardKey.keyD: - if (event is KeyRepeatEvent) return KeyEventResult.handled; - drawing.toggle(); case LogicalKeyboardKey.escape when drawing.active: drawing.stop(); case LogicalKeyboardKey.space: diff --git a/lib/widgets/replay/replay_match_card.dart b/lib/widgets/replay/replay_match_card.dart index 61c761390..9fffe7e16 100644 --- a/lib/widgets/replay/replay_match_card.dart +++ b/lib/widgets/replay/replay_match_card.dart @@ -5,9 +5,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/collab/replay_capture_record.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; import 'package:icarus/providers/collab/convex_connection_provider.dart'; import 'package:icarus/providers/collab/replay_capture_queue_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/replay/replay_capture.dart'; import 'package:icarus/replay/replay_document.dart'; import 'package:icarus/replay/replay_drawing.dart'; @@ -234,6 +236,10 @@ class _ReplayMatchCardState extends ConsumerState { final strategy = _capture.strategy; final inCloud = strategy?.source == StrategySource.cloud; final queue = ref.watch(replayCaptureQueueProvider); + final drawKey = ShortcutInfo.displayLabelFor( + IcarusShortcutAction.draw.name, + ref.watch(appPreferencesProvider).customShortcutBindings, + ); // A strategy found deleted or on another map holds none of them now. final captured = !inCloud ? strategy?.pageCount ?? 0 @@ -308,7 +314,9 @@ class _ReplayMatchCardState extends ConsumerState { select: () => widget.drawing.active, builder: (context, active) => _ToolButton( icon: LucideIcons.pencil, - tooltip: active ? 'Stop drawing (D)' : 'Draw (D)', + tooltip: active + ? 'Stop drawing ($drawKey)' + : 'Draw ($drawKey)', selected: active, onPressed: widget.drawing.toggle, ), diff --git a/test/replay_drawing_test.dart b/test/replay_drawing_test.dart index bc0b17d38..04c132048 100644 --- a/test/replay_drawing_test.dart +++ b/test/replay_drawing_test.dart @@ -281,15 +281,15 @@ void main() { } testWidgets( - 'D and the pen button take the pen out and pause; Esc and D ' - 'put it away', (tester) async { + "The editor's Draw key (Q) and the pen button take the pen out and " + 'pause; Esc and Q put it away', (tester) async { await openReplay(tester); final playback = canvas(tester).playback; final drawing = canvas(tester).drawing; await tester.sendKeyEvent(LogicalKeyboardKey.space); expect(playback.playing, isTrue); - await tester.sendKeyEvent(LogicalKeyboardKey.keyD); + await tester.sendKeyEvent(LogicalKeyboardKey.keyQ); await tester.pump(); expect(drawing.active, isTrue); expect(playback.playing, isFalse); @@ -311,8 +311,11 @@ void main() { await tester.pump(); expect(drawing.active, isFalse); - await tester.sendKeyEvent(LogicalKeyboardKey.keyD); + await tester.sendKeyEvent(LogicalKeyboardKey.keyQ); expect(drawing.active, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.keyQ); + expect(drawing.active, isFalse); + // D is the editor's Next page, not the pen. await tester.sendKeyEvent(LogicalKeyboardKey.keyD); expect(drawing.active, isFalse); // Esc with the pen away is left to whoever else wants it. From d22fa391bd92a68aa1ca29c7fc44a5a365dcd4a1 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Thu, 15 Oct 2026 19:33:34 -0400 Subject: [PATCH 3/3] Draw the defense capture test's stroke off-centre Through the map's centre a stroke looks the same flipped or not, so the test could not tell a missing defense flip. Off-centre, and with the mirrored point checked empty in the editor, it fails without the flip. Co-Authored-By: Claude Opus 5.5 --- test/replay_drawing_test.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/replay_drawing_test.dart b/test/replay_drawing_test.dart index 04c132048..da3258258 100644 --- a/test/replay_drawing_test.dart +++ b/test/replay_drawing_test.dart @@ -400,7 +400,8 @@ void main() { await tester.pump(); final layer = find.byType(ReplayDrawingLayer); final world = tester.getRect(layer); - final from = world.center - const Offset(100, 0); + // Off-centre, so a missing flip on defense would move it. + final from = world.center + const Offset(-300, -150); final gesture = await tester.startGesture(from, kind: PointerDeviceKind.mouse); for (var i = 0; i < 10; i++) { @@ -658,4 +659,11 @@ Future _expectEditorDraws( 0, reason: 'nothing drawn away from the stroke', ); + expect( + (await _pixel(tester, finder, + Offset((1 - at.dx) * world.width, (1 - at.dy) * world.height))) + .a, + 0, + reason: 'nothing drawn where the other side would put it', + ); }