diff --git a/lib/replay/replay_capture.dart b/lib/replay/replay_capture.dart index 51a54b76..480ac973 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 00000000..7d4152af --- /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 4e9b2b5f..57839567 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'; @@ -16,6 +17,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 +64,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 +123,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 +257,7 @@ class _ReplayViewState extends ConsumerState _loader.cancel(); _ticker.dispose(); _coneCuts?.dispose(); + _drawing?.dispose(); _playback ?..removeListener(_syncMapSide) ..dispose(); @@ -256,7 +266,25 @@ class _ReplayViewState extends ConsumerState KeyEventResult _onKey(FocusNode node, KeyEvent event) { final playback = _playback; - if (playback == null) return KeyEventResult.ignored; + final drawing = _drawing; + 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; + } + // Esc puts the pen away before it stops watching a player. + if (event.logicalKey == LogicalKeyboardKey.escape && drawing.active) { + drawing.stop(); + return KeyEventResult.handled; + } return replayKey(playback, event); } @@ -275,6 +303,7 @@ class _ReplayViewState extends ConsumerState child: playback != null ? _ReplayBench( playback: playback, + drawing: _drawing!, map: map!, onReturnFromEditor: claimEditorState, ) @@ -405,11 +434,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; @@ -421,6 +452,7 @@ class _ReplayBench extends StatelessWidget { child: RepaintBoundary( child: ReplayCanvas( playback: playback, + drawing: drawing, map: map, reservedRight: ReplayRoster.width + 16, ), @@ -432,6 +464,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 bdcc6575..d7d5f53d 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 6bf91cba..d905dc8b 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -11,6 +11,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'; @@ -20,21 +21,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; @@ -153,6 +158,9 @@ class _ReplayCanvasState extends ConsumerState /// The player the camera follows, by subject. String? _followed; + /// Whether a drag pans, as it does unless the pen is out. + late bool _panEnabled = !widget.drawing.active; + @override void initState() { super.initState(); @@ -163,6 +171,7 @@ class _ReplayCanvasState extends ConsumerState duration: const Duration(milliseconds: 220), )..addListener(_follow); widget.playback.addListener(_playbackChanged); + widget.drawing.addListener(_drawingChanged); } @override @@ -172,12 +181,22 @@ class _ReplayCanvasState extends ConsumerState oldWidget.playback.removeListener(_playbackChanged); widget.playback.addListener(_playbackChanged); } + 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.playback.removeListener(_playbackChanged); _approach.dispose(); + widget.drawing.removeListener(_drawingChanged); _controller.dispose(); _cones.dispose(); super.dispose(); @@ -246,6 +265,8 @@ class _ReplayCanvasState extends ConsumerState /// A click on an agent watches them; one on empty map does nothing, so /// the watch is never lost by accident. [point] is in canvas pixels. void _tapped(Offset point) { + // With the pen out, a click is the start of a stroke. + if (widget.drawing.active) return; final playback = widget.playback; final frame = playback.frame; final agent = replayAgentAt( @@ -335,6 +356,7 @@ class _ReplayCanvasState extends ConsumerState alignment: Alignment.topLeft, minScale: 1, maxScale: 8, + panEnabled: _panEnabled, boundaryMargin: const EdgeInsets.all(double.infinity), onInteractionStart: _interactionStarted, onInteractionUpdate: _interactionUpdated, @@ -343,7 +365,13 @@ class _ReplayCanvasState extends ConsumerState 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]; @@ -547,6 +575,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 00000000..27080b1a --- /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 2ee30f6c..b86038e1 100644 --- a/lib/widgets/replay/replay_match_card.dart +++ b/lib/widgets/replay/replay_match_card.dart @@ -5,11 +5,14 @@ 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'; import 'package:icarus/replay/replay_playback.dart'; import 'package:icarus/replay_view.dart'; import 'package:icarus/services/app_error_reporter.dart'; @@ -18,17 +21,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 @@ -64,8 +69,11 @@ class _ReplayMatchCardState extends ConsumerState { // A fresh frame: the saved page must not share objects with the one // on screen. final frame = playback.freshFrame(); - 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( @@ -225,6 +233,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 @@ -294,6 +306,19 @@ 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 ($drawKey)' + : 'Draw ($drawKey)', + selected: active, + onPressed: widget.drawing.toggle, + ), + ), + const SizedBox(width: 8), ShadTooltip( builder: (context) => const Text("See the other team's side"), @@ -307,6 +332,7 @@ class _ReplayMatchCardState extends ConsumerState { ), ], ), + _DrawingTools(drawing: widget.drawing), if (captured > 0) ...[ const SizedBox(height: 8), Row( @@ -471,6 +497,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 21450778..512d0037 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,9 +18,7 @@ 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_document.dart'; @@ -29,41 +26,9 @@ 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/strategy/strategy_page_models.dart'; -import 'package:uuid/uuid.dart'; import 'replay_test_support.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; -} - ReplayFrame frame({Offset position = const Offset(500, 500)}) => ReplayFrame( timeMs: 1000, round: null, diff --git a/test/replay_drawing_test.dart b/test/replay_drawing_test.dart new file mode 100644 index 00000000..da325825 --- /dev/null +++ b/test/replay_drawing_test.dart @@ -0,0 +1,669 @@ +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( + "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.keyQ); + 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.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. + 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); + // 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++) { + 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', + ); + 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', + ); +} diff --git a/test/replay_test_support.dart b/test/replay_test_support.dart index 0fbd1393..d85b19bc 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; +} diff --git a/test/replay_watch_test.dart b/test/replay_watch_test.dart index 4a7c0613..5bbe7e23 100644 --- a/test/replay_watch_test.dart +++ b/test/replay_watch_test.dart @@ -11,6 +11,7 @@ import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/replay/replay_cone_cuts.dart'; import 'package:icarus/replay/replay_document.dart'; +import 'package:icarus/replay/replay_drawing.dart'; import 'package:icarus/replay/replay_frame.dart'; import 'package:icarus/replay/replay_map_projection.dart'; import 'package:icarus/replay/replay_playback.dart'; @@ -443,6 +444,8 @@ void main() { addTearDown(tester.view.resetPhysicalSize); final p = playback(tenPlayers(walker: 'r2'))..seek(1000); addTearDown(p.dispose); + final drawing = ReplayDrawing(p); + addTearDown(drawing.dispose); await tester.pumpWidget(ProviderScope( overrides: [ appPreferencesProvider.overrideWith(_Preferences.new), @@ -452,6 +455,7 @@ void main() { home: Scaffold( body: ReplayCanvas( playback: p, + drawing: drawing, map: projection.map, reservedRight: reservedRight, ),