Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/interactive_map.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:icarus/const/maps.dart';
import 'package:icarus/const/settings.dart';
import 'package:icarus/providers/ability_bar_provider.dart';
import 'package:icarus/providers/canvas_resize_provider.dart';
import 'package:icarus/providers/canvas_selection_provider.dart';
import 'package:icarus/providers/collab/strategy_capabilities_provider.dart';
import 'package:icarus/providers/interaction_state_provider.dart';
import 'package:icarus/providers/map_provider.dart';
Expand Down Expand Up @@ -150,6 +151,8 @@ class _InteractiveMapState extends ConsumerState<InteractiveMap> {

@override
Widget build(BuildContext context) {
// A drag of the selection moves the cones without a new layout.
ref.listen(canvasGroupDragProvider, (_, __) => _cones.moved());
bool isAttack = ref.watch(mapProvider).isAttack;
final canEditPages = ref.watch(
currentStrategyCapabilitiesProvider.select(
Expand Down
11 changes: 7 additions & 4 deletions lib/providers/action_history_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:icarus/const/bounding_box.dart';
import 'package:icarus/const/drawing_element.dart';
import 'package:icarus/const/line_provider.dart';
import 'package:icarus/const/placed_classes.dart';
import 'package:icarus/providers/drawing_provider.dart';

class ActionObjectState {
final String id;
Expand Down Expand Up @@ -85,22 +86,23 @@ class ActionObjectState {
}

/// The object as plain JSON, the same fields live sync sends. Only kinds
/// that record field edits have one; drawings are only added and removed,
/// and lineups keep their own history.
/// that record field edits have one; lineups keep their own history.
Map<String, dynamic> toJson() {
final Object encoded = switch (kind) {
ActionObjectKind.agent => agent!.toJson(),
ActionObjectKind.ability => ability!.toJson(),
ActionObjectKind.drawing => DrawingProvider.elementToJson(drawing!),
ActionObjectKind.text => text!.toJson(),
ActionObjectKind.image => image!.toJson(),
ActionObjectKind.utility => utility!.toJson(),
ActionObjectKind.drawing ||
ActionObjectKind.lineUp =>
throw UnsupportedError('$kind records no field edits'),
};
return jsonDecode(jsonEncode(encoded)) as Map<String, dynamic>;
}

/// The object [toJson] wrote. A free drawing comes back without its
/// screen path; whoever puts it on the canvas rebuilds it.
factory ActionObjectState.fromJson(
ActionObjectKind kind,
Map<String, dynamic> json,
Expand All @@ -110,13 +112,14 @@ class ActionObjectState {
ActionObjectState.agent(PlacedAgentNode.fromJson(json)),
ActionObjectKind.ability =>
ActionObjectState.ability(PlacedAbility.fromJson(json)),
ActionObjectKind.drawing =>
ActionObjectState.drawing(DrawingProvider.elementFromJson(json)),
ActionObjectKind.text =>
ActionObjectState.text(PlacedText.fromJson(json)),
ActionObjectKind.image =>
ActionObjectState.image(PlacedImage.fromJson(json)),
ActionObjectKind.utility =>
ActionObjectState.utility(PlacedUtility.fromJson(json)),
ActionObjectKind.drawing ||
ActionObjectKind.lineUp =>
throw UnsupportedError('$kind records no field edits'),
};
Expand Down
95 changes: 95 additions & 0 deletions lib/providers/canvas_selection_provider.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:ui';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icarus/const/line_provider.dart';
import 'package:icarus/providers/ability_provider.dart';
Expand Down Expand Up @@ -48,6 +50,44 @@ final selectedCanvasIdsProvider = Provider<Set<String>>((ref) {
};
});

/// The ids of the selected drawings.
final selectedDrawingIdsProvider = Provider<Set<String>>((ref) {
return {
for (final item in ref.watch(canvasSelectionProvider))
if (item.kind == SelectableKind.drawing) item.id,
};
});

/// A drag of the selection under way: the items it carries, as they were
/// when it began, and how far it has carried them, in canvas pixels.
typedef CanvasGroupDrag = ({Set<String> ids, Offset by});

/// The drag of the selection under way, or null. Its items are drawn this
/// far from where they are saved until the drag ends and moves them.
final canvasGroupDragProvider =
NotifierProvider<CanvasGroupDragNotifier, CanvasGroupDrag?>(
CanvasGroupDragNotifier.new,
);

class CanvasGroupDragNotifier extends Notifier<CanvasGroupDrag?> {
@override
CanvasGroupDrag? build() => null;

void set(CanvasGroupDrag? drag) => state = drag;
}

/// Whether item [id] is one of several selected items that move together:
/// a drag on it drags them all, so its own drag stands aside.
final dragsWithSelectionProvider = Provider.family<bool, String>((ref, id) {
final selection = ref.watch(canvasSelectionProvider);
return selection.length > 1 &&
selection.any((item) => item.id == id && movesWithSelection(item.kind));
});

/// Whether [kind] moves when the selection is dragged. A lineup marks the
/// spot its screenshots were taken from, so it stays where it is.
bool movesWithSelection(SelectableKind kind) => kind != SelectableKind.lineup;

class CanvasSelectionNotifier extends Notifier<Set<SelectedItem>> {
@override
Set<SelectedItem> build() {
Expand Down Expand Up @@ -123,6 +163,61 @@ class CanvasSelectionNotifier extends Notifier<Set<SelectedItem>> {
if (state.isNotEmpty) state = const {};
}

/// Moves the items [ids] (a drag of the selection, as it was when the
/// drag began) by [by], in world coordinates, as one undo step. Lineups
/// never move this way (see [movesWithSelection]).
void moveItems(Set<String> ids, Offset by) {
if (ids.isEmpty ||
by == Offset.zero ||
!ref.read(currentStrategyCapabilitiesProvider).canEditPages) {
return;
}
ref.read(actionProvider.notifier).performTransaction(
groups: const [],
mutation: () {
// In each kind's own order: a move brings an item to the front, so
// the moved items keep their order among themselves.
for (final agent in [...ref.read(agentProvider)]) {
if (!ids.contains(agent.id)) continue;
ref
.read(agentProvider.notifier)
.updatePosition(agent.position + by, agent.id);
}
for (final ability in [...ref.read(abilityProvider)]) {
if (!ids.contains(ability.id)) continue;
ref
.read(abilityProvider.notifier)
.updatePosition(ability.position + by, ability.id);
}
for (final text in [...ref.read(textProvider)]) {
if (!ids.contains(text.id)) continue;
ref
.read(textProvider.notifier)
.updatePosition(text.position + by, text.id);
}
for (final image in [...ref.read(placedImageProvider).images]) {
if (!ids.contains(image.id)) continue;
ref
.read(placedImageProvider.notifier)
.updatePosition(image.position + by, image.id);
}
for (final utility in [...ref.read(utilityProvider)]) {
if (!ids.contains(utility.id)) continue;
ref
.read(utilityProvider.notifier)
.updatePosition(utility.position + by, utility.id);
}
final drawingIds = {
for (final drawing in ref.read(drawingProvider).elements)
if (ids.contains(drawing.id)) drawing.id,
};
if (drawingIds.isNotEmpty) {
ref.read(drawingProvider.notifier).translateDrawings(drawingIds, by);
}
},
);
}

/// Deletes everything selected as one undo step.
void deleteSelected() {
final selection = state;
Expand Down
111 changes: 111 additions & 0 deletions lib/providers/drawing_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ class DrawingProvider extends Notifier<DrawingState> {
_upsertDrawing(cloneDrawingElement(before));
return;
case ActionType.edit:
_writeEdit(action.id, delta.undoOnto);
return;
case ActionType.bulkDeletion:
case ActionType.transaction:
return;
Expand Down Expand Up @@ -138,12 +140,64 @@ class DrawingProvider extends Notifier<DrawingState> {
_removeDrawingById(action.id);
return;
case ActionType.edit:
_writeEdit(action.id, delta.redoOnto);
return;
case ActionType.bulkDeletion:
case ActionType.transaction:
return;
}
}

/// Writes an edit onto the drawing [id] where it sits in the stack. The
/// written copy comes from JSON or history, so its screen path is rebuilt
/// for the canvas as it is now.
void _writeEdit(
String id,
ActionObjectState Function(ActionObjectState current) write,
) {
final index = DrawingElement.getIndexByID(id, state.elements);
if (index < 0) return;
final written =
write(ActionObjectState.drawing(state.elements[index])).drawing!;
if (written is FreeDrawing) {
written.rebuildPath(CoordinateSystem.instance);
}
_upsertDrawing(written);
}

/// Moves the drawings named in [ids] by [worldDelta] (world units, in the
/// attack-side coordinates drawings are stored in), each keeping its place
/// in the stack, and records one edit per drawing moved. A group move runs
/// this inside [ActionProvider.performTransaction] so the whole move is
/// one undo step. Nothing here keeps the drawings on the map; the caller
/// limits [worldDelta].
void translateDrawings(Set<String> ids, Offset worldDelta) {
if (!ref.canEditPages || ids.isEmpty || worldDelta == Offset.zero) return;
final coordinateSystem = CoordinateSystem.instance;
final newElements = [...state.elements];
final edits = <UserAction>[];
for (final (index, drawing) in state.elements.indexed) {
if (!ids.contains(drawing.id)) continue;
final moved = _translated(drawing, worldDelta, coordinateSystem);
newElements[index] = moved;
edits.add(UserAction(
type: ActionType.edit,
id: drawing.id,
group: ActionGroup.drawing,
objectDelta: ObjectHistoryDelta(
before: ActionObjectState.drawing(drawing),
after: ActionObjectState.drawing(moved),
),
));
}
if (edits.isEmpty) return;

state = state.copyWith(elements: newElements);
_triggerRepaint();
final history = ref.read(actionProvider.notifier);
edits.forEach(history.addAction);
}

String toJson() {
final List<Map<String, dynamic>> jsonList =
state.elements.map(_serializeDrawingElement).toList();
Expand Down Expand Up @@ -187,6 +241,15 @@ class DrawingProvider extends Notifier<DrawingState> {
return result;
}

/// One drawing as the JSON saves and live sync write for it.
static Map<String, dynamic> elementToJson(DrawingElement element) =>
_serializeDrawingElement(element);

/// The drawing [elementToJson] wrote. A free drawing comes back without
/// its screen path; rebuild it before putting it on the canvas.
static DrawingElement elementFromJson(Map<String, dynamic> json) =>
_deserializeDrawingElement(json);

static Map<String, dynamic> _serializeDrawingElement(DrawingElement element) {
if (element is FreeDrawing) {
return {
Expand Down Expand Up @@ -1003,6 +1066,54 @@ int _readDrawingColorValue(Map<String, dynamic> json) {
return 0xFFFFFFFF;
}

/// [drawing] moved by [delta] in world units: every point and its bounding
/// box shift together, and a free drawing's screen path is rebuilt.
DrawingElement _translated(
DrawingElement drawing,
Offset delta,
CoordinateSystem coordinateSystem,
) {
final box = drawing.boundingBox;
final movedBox = box == null
? null
: BoundingBox(min: box.min + delta, max: box.max + delta);
return switch (drawing) {
FreeDrawing() => drawing.copyWith(
listOfPoints: [for (final point in drawing.listOfPoints) point + delta],
boundingBox: movedBox,
cachedPolylineLengthUnits: drawing.cachedPolylineLengthUnits,
)..rebuildPath(coordinateSystem),
Line() => drawing.copyWith(
lineStart: drawing.lineStart + delta,
lineEnd: drawing.lineEnd + delta,
boundingBox: movedBox,
),
RectangleDrawing() => RectangleDrawing(
start: drawing.start + delta,
end: drawing.end + delta,
colorValue: drawing.colorValue,
thickness: drawing.thickness,
boundingBox: movedBox,
isDotted: drawing.isDotted,
hasArrow: drawing.hasArrow,
id: drawing.id,
),
EllipseDrawing() => EllipseDrawing(
start: drawing.start + delta,
end: drawing.end + delta,
colorValue: drawing.colorValue,
thickness: drawing.thickness,
boundingBox: movedBox,
isDotted: drawing.isDotted,
hasArrow: drawing.hasArrow,
id: drawing.id,
),
_ => throw UnsupportedError(
'Unsupported drawing element: ${drawing.runtimeType}',
),
};
}

BoundingBox _boundingBoxForPoints(Offset a, Offset b) {
return BoundingBox(
min: Offset(min(a.dx, b.dx), min(a.dy, b.dy)),
Expand Down
4 changes: 4 additions & 0 deletions lib/view_cone/cone_coverage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class ConeCoverage extends ChangeNotifier {
}

void _changed() => notifyListeners();

/// Repaints the layer from where the cones sit now, after something above
/// them moved them without a new layout (a drag of the selection).
void moved() => notifyListeners();
}

/// How much of the way a cone lifts the floor toward the theme's wall
Expand Down
Loading
Loading