From bbd99b23238db06542f12901db6486f923802af9 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 9 Oct 2026 23:42:28 -0400 Subject: [PATCH 1/7] Move and copy lineups to another page Lineup right-click menus gain Move to page and Copy to page, each listing every other page. The lineups at the clicked spot go to that page under new ids, with their names, notes and screenshots; a spot one of them shares with a lineup that stays is copied, so that lineup keeps it. They go on the other page before they leave this one. On cloud strategies the other page is read first and the new groups are queued as work the canvas never drew, so they are not deleted when they land while that page is on screen. Co-Authored-By: Claude Opus 5.5 --- lib/const/line_provider.dart | 50 +++ lib/providers/strategy_provider.dart | 238 +++++++++++++++ .../ability_visibility_context_menu.dart | 2 + .../agents/agent_widget.dart | 6 + .../draggable_widgets/lineup_page_menu.dart | 101 ++++++ test/strategy_page_semantics_test.dart | 222 ++++++++++++++ test/strategy_page_session_provider_test.dart | 289 ++++++++++++++++++ 7 files changed, 908 insertions(+) create mode 100644 lib/widgets/draggable_widgets/lineup_page_menu.dart diff --git a/lib/const/line_provider.dart b/lib/const/line_provider.dart index 06b4a0fe..6053961e 100644 --- a/lib/const/line_provider.dart +++ b/lib/const/line_provider.dart @@ -372,6 +372,50 @@ class LineUpGraph { ); } + /// The lineups [linkIds] and the spots they aim at, under new ids, ready + /// to be put on another page. Spots they share stay shared in the copy. + LineUpGraph copyOfLinks(Set linkIds) { + const uuid = Uuid(); + final newIds = {}; + String renamed(String id) => newIds[id] ??= uuid.v4(); + final copied = [ + for (final link in links) + if (linkIds.contains(link.id)) link, + ]; + final originIds = {for (final link in copied) link.originId}; + final landingIds = {for (final link in copied) link.landingId}; + return LineUpGraph( + origins: [ + for (final origin in origins) + if (originIds.contains(origin.id)) + LineUpOrigin( + id: renamed(origin.id), + agent: origin.agent + .deepCopy() + .copyWith(lineUpID: renamed(origin.id)), + ), + ], + landings: [ + for (final landing in landings) + if (landingIds.contains(landing.id)) + LineUpLanding( + id: renamed(landing.id), + ability: landing.ability + .deepCopy() + .copyWith(lineUpID: renamed(landing.id)), + ), + ], + links: [ + for (final link in copied) + link.deepCopy().copyWith( + id: uuid.v4(), + originId: renamed(link.originId), + landingId: renamed(link.landingId), + ), + ], + ); + } + Map toJson() { return { 'lineUpOrigins': origins.map((origin) => origin.toJson()).toList(), @@ -1327,6 +1371,12 @@ class LineUpProvider extends Notifier { _recordDeletion(linkId, {linkId}); } + /// Removes [linkIds] as one deletion, for lineups moved to another page. + void deleteLinks(Set linkIds) { + if (linkIds.isEmpty) return; + _recordDeletion((linkIds.toList()..sort()).first, linkIds); + } + void deleteOrigin(String originId) { if (state.originById(originId) == null) return; _recordDeletion( diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 1b4dd811..33b40354 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -38,6 +38,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; import 'package:icarus/collab/canonical_json.dart'; import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; import 'package:icarus/collab/strategy_capabilities.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/providers/collab/remote_library_provider.dart'; @@ -76,6 +77,32 @@ enum PageCopyResult { unavailable, } +/// A page lineups can be moved or copied to: every page but the one on +/// screen, in page order. [offset] is its distance from the page on screen, +/// so -1 is the previous page and 1 the next. +typedef LineUpPageTarget = ({String id, String name, int offset}); + +/// What became of moving or copying lineups to another page. +enum LineUpPageResult { + /// The lineups are on the other page, and, for a move, gone from this one. + done, + + /// A move put the lineups on the other page, but before they could be + /// taken off this one, the page on screen changed or one of them did (a + /// teammate's edit), so they are on both. + copiedInstead, + + /// The cloud page could not be read, so nothing was moved or copied. + unreachable, + + /// This device could not store the lineups to send, so nothing was moved + /// or copied. + notSaved, + + /// There was nothing to move or copy, or no such page. + unavailable, +} + class StrategyProvider extends Notifier { @override StrategyState build() { @@ -1096,6 +1123,217 @@ class StrategyProvider extends Notifier { return elements; } + /// The pages lineups on screen can be moved or copied to. + List lineUpPageTargets() { + final pages = _orderedPagesForLineUps(); + final currentIndex = pages.indexWhere( + (page) => page.id == ref.read(strategyPageSessionProvider).activePageId, + ); + if (currentIndex < 0) return const []; + return [ + for (final (index, page) in pages.indexed) + if (index != currentIndex) + (id: page.id, name: page.name, offset: index - currentIndex), + ]; + } + + /// The strategy's pages in order, as ids and names, or none when lineups + /// cannot be sent to them. + List<({String id, String name})> _orderedPagesForLineUps() { + if (_currentStrategyIsCloud()) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || + snapshot.header.publicId != state.strategyId || + !_currentStrategyCanEditPages()) { + return const []; + } + return [ + for (final page in [ + ...snapshot.pages + ]..sortBySortIndex((item) => item.sortIndex)) + (id: page.publicId, name: page.name), + ]; + } + final strategyId = state.strategyId; + if (strategyId == null || !Hive.isBoxOpen(HiveBoxNames.strategiesBox)) { + return const []; + } + final strat = + Hive.box(HiveBoxNames.strategiesBox).get(strategyId); + if (strat == null) return const []; + return [ + for (final page in [ + ...strat.pages + ]..sortBySortIndex((item) => item.sortIndex)) + (id: page.id, name: page.name), + ]; + } + + /// Puts a copy of the lineups [linkIds] on screen, and the spots they aim + /// at, on page [pageId] under new ids, and for a [move] then takes them + /// off this page as a delete would (spots another lineup here still uses + /// stay). The copies keep their media: an image is only cleaned up once + /// nothing in the strategy shows it. + /// + /// The lineups are taken as they are when the user asks. They go on the + /// other page before they leave this one, so a failure never loses them. + Future sendLineUpsToPage({ + required Set linkIds, + required String pageId, + required bool move, + }) async { + final strategyId = state.strategyId; + final sourcePageId = ref.read(strategyPageSessionProvider).activePageId; + final copy = ref.read(lineUpProvider).graph.copyOfLinks(linkIds); + if (strategyId == null || + sourcePageId == null || + copy.links.isEmpty || + !lineUpPageTargets().any((page) => page.id == pageId)) { + return LineUpPageResult.unavailable; + } + + final LineUpPageResult placed; + if (_currentStrategyIsCloud()) { + final queued = _cloudCopies.then( + (_) => _addLineUpsToCloudPage( + strategyId: strategyId, + pageId: pageId, + lineUps: copy, + ), + ); + _cloudCopies = queued.then((_) {}, onError: (_) {}); + placed = await queued; + } else { + placed = await _addLineUpsToLocalPage( + strategyId: strategyId, + pageId: pageId, + lineUps: copy, + ); + } + if (placed != LineUpPageResult.done || !move) return placed; + + final lineUps = ref.read(lineUpProvider); + if (state.strategyId != strategyId || + ref.read(strategyPageSessionProvider).activePageId != sourcePageId || + !linkIds.every((id) => lineUps.linkById(id) != null)) { + return LineUpPageResult.copiedInstead; + } + ref.read(lineUpProvider.notifier).deleteLinks(linkIds); + return LineUpPageResult.done; + } + + Future _addLineUpsToLocalPage({ + required String strategyId, + required String pageId, + required LineUpGraph lineUps, + }) async { + await _syncCurrentPageToHive(); + if (state.strategyId != strategyId) return LineUpPageResult.unavailable; + final box = Hive.box(HiveBoxNames.strategiesBox); + final strat = box.get(strategyId); + if (strat == null || !strat.pages.any((page) => page.id == pageId)) { + return LineUpPageResult.unavailable; + } + final updated = strat.copyWith( + pages: [ + for (final page in strat.pages) + if (page.id == pageId) + page.copyWith( + lineUpGraph: LineUpGraph( + origins: [...page.lineUpOrigins, ...lineUps.origins], + landings: [...page.lineUpLandings, ...lineUps.landings], + links: [...page.lineUpLinks, ...lineUps.links], + ), + ) + else + page, + ], + lastEdited: DateTime.now(), + ); + await box.put(updated.id, updated); + return LineUpPageResult.done; + } + + /// Queues [lineUps] onto cloud page [pageId] as new lineup groups after + /// the page's own. The page is read from the server first, for where its + /// groups end; if it cannot be read, nothing is queued. + Future _addLineUpsToCloudPage({ + required String strategyId, + required String pageId, + required LineUpGraph lineUps, + }) async { + if (state.strategyId != strategyId) return LineUpPageResult.unavailable; + final RemotePageSnapshot targetPage; + try { + targetPage = + await ref.read(convexStrategyRepositoryProvider).fetchPageSnapshot( + strategyPublicId: strategyId, + pagePublicId: pageId, + ); + } catch (error) { + log('Could not read page $pageId to put lineups on it: $error'); + return LineUpPageResult.unreachable; + } + if (state.strategyId != strategyId) return LineUpPageResult.unavailable; + + // Every id in the copy is new, so each group's id (its smallest lineup + // id) is one no row in the strategy has. + final rows = cloudLineupRows(lineUps).rows; + var sortIndex = + 1 + _cloudLineupSortIndexesOn(targetPage).fold(-1, max); + final queue = ref.read(strategyOpQueueProvider.notifier); + for (final row in rows) { + // The canvas never draws these: their page shows them from the server. + final queued = await queue.enqueueOffCanvas( + LineupAddOp( + opId: const Uuid().v4(), + lineupPublicId: row.publicId, + pagePublicId: pageId, + payload: row.payload, + sortIndex: sortIndex++, + ), + flushImmediately: true, + ); + if (!queued) return LineUpPageResult.notSaved; + } + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); + return LineUpPageResult.done; + } + + /// The sortIndexes of the lineup groups on [page] as the server has them, + /// with the work still queued for it laid over. + Iterable _cloudLineupSortIndexesOn(RemotePageSnapshot page) { + final pageId = page.page.publicId; + final groups = { + for (final lineup in page.lineups) + if (!lineup.deleted) lineup.publicId: lineup.sortIndex, + }; + final queue = ref.read(strategyOpQueueProvider); + for (final pendingByKey in [ + queue.attentionByEntityKey.map((k, v) => MapEntry(k, v.pending)), + queue.pausedByEntityKey.map((k, v) => MapEntry(k, v.pending)), + queue.queuedByEntityKey.map((k, v) => MapEntry(k, v.pending)), + queue.inFlightByEntityKey.map((k, v) => MapEntry(k, v.pending)), + queue.successorByEntityKey.map((k, v) => MapEntry(k, v.pending)), + ]) { + pendingByKey.forEach((key, pending) { + if (key.kind != EntitySyncKeyKind.lineup || key.pageId != pageId) { + return; + } + switch (pending.op) { + case LineupDeleteOp(): + groups.remove(key.entityId); + case final op: + groups[key.entityId!] = op.sortIndex ?? groups[key.entityId!] ?? 0; + } + }); + } + return groups.values; + } + static StrategyPage? _copyPlacedWidgetBetweenPages({ required String widgetId, required StrategyPage source, diff --git a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart index 762e441c..20fbb192 100644 --- a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart +++ b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart @@ -16,6 +16,7 @@ import 'package:icarus/widgets/draggable_widgets/adjacent_page_copy_menu.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:icarus/widgets/draggable_widgets/lineup_page_menu.dart'; bool supportsAbilityVisibilityMenu(Ability? ability) { return supportsAbilityInactiveState(ability) || @@ -122,6 +123,7 @@ List buildLandingLineUpMenuItems( .editLineUpPlacement(links.first.id); }, ), + ...buildLineUpPageMenuItems(ref, links), ShadContextMenuItem( leading: Icon( LucideIcons.trash2, diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index 7ead4030..fd670e4d 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -31,6 +31,7 @@ import 'package:icarus/widgets/mouse_watch.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:icarus/widgets/draggable_widgets/lineup_page_menu.dart'; /// Grayscale color matrix for dead agents const List _identityColorMatrix = [ @@ -315,6 +316,11 @@ class AgentWidget extends ConsumerWidget { .editLineUpPlacement(links.first.id); }, ), + if (canInteract && lineUpId != null) + ...buildLineUpPageMenuItems( + ref, + ref.read(lineUpProvider.notifier).linksFromOrigin(lineUpId!), + ), if (canInteract && lineUpId != null) ShadContextMenuItem( leading: Icon( diff --git a/lib/widgets/draggable_widgets/lineup_page_menu.dart b/lib/widgets/draggable_widgets/lineup_page_menu.dart new file mode 100644 index 00000000..ce33d030 --- /dev/null +++ b/lib/widgets/draggable_widgets/lineup_page_menu.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// "Move to page" and "Copy to page" for the lineups [links] (every lineup +/// at the spot the user right-clicked), each listing the strategy's other +/// pages. A spot one of them shares with a lineup that stays is copied, so +/// that lineup keeps it. +List buildLineUpPageMenuItems( + WidgetRef ref, + List links, +) { + if (links.isEmpty) return const []; + final notifier = ref.read(strategyProvider.notifier); + final pages = notifier.lineUpPageTargets(); + if (pages.isEmpty) return const []; + + final linkIds = {for (final link in links) link.id}; + final what = links.length > 1 + ? '${links.length} lineups' + : links.single.name.trim().isEmpty + ? 'the lineup' + : '“${links.single.name.trim()}”'; + + Future send(LineUpPageTarget page, {required bool move}) async { + final result = await notifier.sendLineUpsToPage( + linkIds: linkIds, + pageId: page.id, + move: move, + ); + final nothing = move ? 'nothing was moved' : 'nothing was copied'; + switch (result) { + case LineUpPageResult.done: + Settings.showToast( + message: '${move ? 'Moved' : 'Copied'} $what to ${page.name}.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + case LineUpPageResult.copiedInstead: + Settings.showToast( + message: 'Copied $what to ${page.name}. This page changed ' + 'meanwhile, so it is still here too.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + case LineUpPageResult.unreachable: + Settings.showToast( + message: "Couldn't reach the cloud, so $nothing.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + case LineUpPageResult.notSaved: + Settings.showToast( + message: "Couldn't save the change on this device, so $nothing.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + case LineUpPageResult.unavailable: + break; + } + } + + List pageItems({required bool move}) => [ + for (final page in pages) + ShadContextMenuItem( + trailing: page.offset.abs() == 1 + ? Text( + page.offset < 0 ? 'previous' : 'next', + style: TextStyle( + fontSize: 12, + color: Settings.tacticalVioletTheme.mutedForeground, + ), + ) + : null, + onPressed: () => send(page, move: move), + child: Text(page.name), + ), + ]; + + return [ + ShadContextMenuItem( + leading: const Icon(LucideIcons.fileInput, size: 16), + trailing: const Icon(LucideIcons.chevronRight, size: 16), + items: pageItems(move: true), + child: Text( + links.length > 1 + ? 'Move ${links.length} lineups to page' + : 'Move to page', + ), + ), + ShadContextMenuItem( + leading: const Icon(LucideIcons.copyPlus, size: 16), + trailing: const Icon(LucideIcons.chevronRight, size: 16), + items: pageItems(move: false), + child: Text( + links.length > 1 + ? 'Copy ${links.length} lineups to page' + : 'Copy to page', + ), + ), + ]; +} diff --git a/test/strategy_page_semantics_test.dart b/test/strategy_page_semantics_test.dart index ecaed9b2..7df738fe 100644 --- a/test/strategy_page_semantics_test.dart +++ b/test/strategy_page_semantics_test.dart @@ -15,6 +15,7 @@ import 'package:icarus/const/transition_data.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/migrations/page_name_provenance_migration.dart'; +import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; @@ -328,6 +329,223 @@ void main() { expect(savedTarget.agentData.single.position, const Offset(300, 400)); }, ); + + group('moving and copying lineups to another page', () { + // Sova stands at two spots. Two lineups from them meet at one landing; + // a third goes from the first spot to another landing. + LineUpGraph sovaLineUps() { + final ability = AgentData.agents[AgentType.sova]!.abilities.first; + LineUpOrigin origin(String id, Offset at) => LineUpOrigin( + id: id, + agent: PlacedAgent( + id: 'agent-$id', + type: AgentType.sova, + position: at, + lineUpID: id, + ), + ); + LineUpLanding landing(String id, Offset at) => LineUpLanding( + id: id, + ability: PlacedAbility( + id: 'ability-$id', + data: ability, + position: at, + lineUpID: id, + ), + ); + return LineUpGraph( + origins: [ + origin('stand-1', const Offset(10, 10)), + origin('stand-2', const Offset(20, 10)), + ], + landings: [ + landing('land-1', const Offset(300, 300)), + landing('land-2', const Offset(400, 300)), + ], + links: [ + LineUpLink( + id: 'bolt-a', + originId: 'stand-1', + landingId: 'land-1', + name: 'Bolt A', + notes: 'Jump throw', + images: [SimpleImageData(id: 'shot-a', fileExtension: '.png')], + ), + LineUpLink( + id: 'bolt-b', + originId: 'stand-2', + landingId: 'land-1', + name: 'Bolt B', + ), + LineUpLink( + id: 'recon', + originId: 'stand-1', + landingId: 'land-2', + name: 'Recon', + ), + ], + ); + } + + Future open({ + LineUpGraph targetLineUps = LineUpGraph.empty, + }) async { + final pages = [ + _page(id: 'page-1', name: 'Page 1', sortIndex: 0), + _page( + id: 'page-2', + name: 'Page 2', + sortIndex: 1, + lineUps: sovaLineUps(), + ), + _page(id: 'page-3', name: 'Page 3', sortIndex: 2), + _page( + id: 'page-4', + name: 'Retake', + sortIndex: 3, + lineUps: targetLineUps, + ), + ]; + final strategy = _strategy(pages); + await strategyBox.put(strategy.id, strategy); + final container = ProviderContainer(); + addTearDown(container.dispose); + _activatePage(container, strategy, pages[1]); + return container; + } + + LineUpGraph savedLineUps(String pageId) => strategyBox + .get('strategy-id')! + .pages + .singleWhere((page) => page.id == pageId) + .lineUpGraph; + + test('every other page is offered, in order', () async { + final container = await open(); + expect( + container.read(strategyProvider.notifier).lineUpPageTargets(), + [ + (id: 'page-1', name: 'Page 1', offset: -1), + (id: 'page-3', name: 'Page 3', offset: 1), + (id: 'page-4', name: 'Retake', offset: 2), + ], + ); + }); + + test( + 'a move puts the lineups on the page under new ids and leaves a ' + 'spot another lineup here still uses', () async { + final container = await open(); + + expect( + await container.read(strategyProvider.notifier).sendLineUpsToPage( + linkIds: {'bolt-a', 'bolt-b'}, + pageId: 'page-4', + move: true, + ), + LineUpPageResult.done, + ); + + // This page keeps Recon and the spot it stands at. + final here = container.read(lineUpProvider); + expect(here.links.map((link) => link.id), ['recon']); + expect(here.origins.map((origin) => origin.id), ['stand-1']); + expect(here.landings.map((landing) => landing.id), ['land-2']); + + // The other page has both lineups, still meeting at one landing, with + // their names, notes and screenshots, where they were. + final there = savedLineUps('page-4'); + expect(there.links.map((link) => link.name), ['Bolt A', 'Bolt B']); + expect(there.origins, hasLength(2)); + expect(there.landings, hasLength(1)); + final ids = { + for (final origin in there.origins) origin.id, + for (final landing in there.landings) landing.id, + for (final link in there.links) link.id, + }; + expect(ids, hasLength(5)); + expect( + ids.intersection( + {'stand-1', 'stand-2', 'land-1', 'land-2', 'bolt-a', 'bolt-b'}, + ), + isEmpty, + ); + expect( + there.links.map((link) => link.landingId).toSet(), + {there.landings.single.id}, + ); + expect( + there.links.map((link) => link.originId).toSet(), + {for (final origin in there.origins) origin.id}, + ); + for (final origin in there.origins) { + expect(origin.agent.lineUpID, origin.id); + } + expect(there.landings.single.ability.lineUpID, there.landings.single.id); + expect(there.landings.single.ability.position, const Offset(300, 300)); + final boltA = there.links.first; + expect(boltA.notes, 'Jump throw'); + expect(boltA.images.single.id, 'shot-a'); + expect( + there.origins + .singleWhere((origin) => origin.id == boltA.originId) + .agent + .position, + const Offset(10, 10), + ); + }); + + test('undo after a move brings the lineups back to this page', () async { + final container = await open(); + await container.read(strategyProvider.notifier).sendLineUpsToPage( + linkIds: {'bolt-a', 'bolt-b'}, + pageId: 'page-4', + move: true, + ); + + container.read(actionProvider.notifier).undoAction(); + + expect( + container.read(lineUpProvider).links.map((link) => link.id).toSet(), + {'bolt-a', 'bolt-b', 'recon'}, + ); + }); + + test('a copy leaves this page as it was and adds to what is there', + () async { + final container = await open(targetLineUps: sovaLineUps()); + + expect( + await container.read(strategyProvider.notifier).sendLineUpsToPage( + linkIds: {'recon'}, + pageId: 'page-4', + move: false, + ), + LineUpPageResult.done, + ); + + expect(container.read(lineUpProvider).links, hasLength(3)); + final there = savedLineUps('page-4'); + expect(there.links.map((link) => link.name), + ['Bolt A', 'Bolt B', 'Recon', 'Recon']); + final copy = there.links.last; + expect(copy.id, isNot('recon')); + expect( + there.origins + .singleWhere((origin) => origin.id == copy.originId) + .agent + .position, + const Offset(10, 10), + ); + expect( + there.landings + .singleWhere((landing) => landing.id == copy.landingId) + .ability + .position, + const Offset(400, 300), + ); + }); + }); } StrategyData _strategy(List pages) { @@ -353,6 +571,7 @@ StrategyPage _page({ List text = const [], List images = const [], List utilities = const [], + LineUpGraph lineUps = LineUpGraph.empty, }) { return StrategyPage( id: id, @@ -367,6 +586,9 @@ StrategyPage _page({ textData: text, imageData: images, utilityData: utilities, + lineUpOrigins: lineUps.origins, + lineUpLandings: lineUps.landings, + lineUpLinks: lineUps.links, isAttack: true, settings: StrategySettings(), ); diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index fb446292..9bbf9ccd 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -6528,6 +6528,7 @@ void main() { Completer? hold, List otherPages = const [], Map> otherElements = const {}, + Map> otherLineups = const {}, }) async { final others = { for (final other in otherPages) @@ -6535,6 +6536,7 @@ void main() { other, settings: settingsFor(1), elements: otherElements[other.publicId] ?? const [], + lineups: otherLineups[other.publicId] ?? const [], ), }; var shown = page.publicId; @@ -6775,6 +6777,94 @@ void main() { expect(server.element(copyId).deleted, isFalse); }); + test( + 'lineups copied to another page show there when the user gets there ' + 'before they land', () async { + final source = _page('page-1', 0); + final target = _page('page-2', 1); + server = _FakeServer(target.publicId); + final (container, batches) = await openOnRealQueue( + target, + otherPages: [source], + otherLineups: { + source.publicId: [_lineup(source.publicId, 'a', linkName: 'Smoke')], + }, + ); + readPage(source.publicId); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(container.read(lineUpProvider).links.map((link) => link.id), + ['link-a']); + + // The copy is sent, and its answer held back. + final hold = repository.hold = Completer(); + expect( + await container.read(strategyProvider.notifier).sendLineUpsToPage( + linkIds: {'link-a'}, + pageId: target.publicId, + move: false, + ), + LineUpPageResult.done, + ); + await _until(() => batches.isNotEmpty); + final groupId = (batches.single.single as LineupAddOp).lineupPublicId; + + // The user opens page 2 before the copy lands, then it lands. + final session = container.read(strategyPageSessionProvider.notifier); + await session.setActivePage(target.publicId); + readPage(target.publicId); + for (var i = 0; i < 10; i++) { + await _settle(); + } + // A press off the canvas (the sidebar) holds the page's updates back + // while the copy lands and the page's read shows it. The user places + // an agent meanwhile. + final pointers = container.read(editorPointersProvider.notifier)..down(1); + hold.complete(); + final copyKey = EntitySyncKey.lineup(target.publicId, groupId); + await _until(() => !container.read(strategyOpQueueProvider).pending.any( + (pending) => EntitySyncKey.forStrategyOp(pending.op) == copyKey)); + showServer(); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(container.read(lineUpProvider).links, isEmpty); + container.read(agentProvider.notifier).addAgent(PlacedAgent( + id: 'agent-1', + type: AgentType.jett, + position: const Offset(40, 40), + )); + for (var i = 0; i < 10; i++) { + await _settle(); + } + pointers.release(1); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + await container.read(strategyOpQueueProvider.notifier).flushNow(); + await _until( + () => container.read(strategyOpQueueProvider).pending.isEmpty); + showServer(); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + expect(container.read(lineUpProvider).links.map((link) => link.name), + ['Smoke']); + // Page 2 sent the copy and the agent, and no delete of the copy. + expect( + [ + for (final batch in batches) + for (final op in batch) + if (EntitySyncKey.forStrategyOp(op)?.pageId == target.publicId) + (op.kind, op.entityPublicId), + ], + [(StrategyOpKind.add, groupId), (StrategyOpKind.add, 'agent-1')], + ); + }); + test( 'Keep mine restores a lineup a teammate deleted while the user ' 'edited it', () async { @@ -10646,6 +10736,205 @@ void main() { ); }); }); + + group('moving and copying lineups to another cloud page', () { + final pages = [_page('page-1', 0), _page('page-2', 1), _page('page-3', 2)]; + + // On page 2, lineups a and b meet at one landing; solo is on its own. + final shared = _groupRow( + 'page-2', + 'link-a', + origins: [_originJson('stand-a'), _originJson('stand-b')], + landings: [_landingJson('land-ab')], + links: [ + _linkJson('link-a', + originId: 'stand-a', landingId: 'land-ab', name: 'Bolt A'), + _linkJson('link-b', + originId: 'stand-b', landingId: 'land-ab', name: 'Bolt B'), + ], + ); + + /// Opens the strategy on page 2; the repository reads page 3 with a + /// lineup group of its own at sortIndex 6. + Future<(ProviderContainer, _FakeStrategyOpQueueNotifier, _PageReader)> + open() async { + final onScreen = _pageSnapshot( + pages[1], + lineups: [shared, _lineup('page-2', 'solo', sortIndex: 1)], + ); + final queue = _FakeStrategyOpQueueNotifier(); + final reader = _PageReader({ + 'page-3': _pageSnapshot( + pages[2], + lineups: [_lineup('page-3', 'there', sortIndex: 6)], + ), + }); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier( + _editorSnapshot(pages: pages, activePage: onScreen), + ), + queue: queue, + repository: reader, + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + preferredPageId: 'page-2', + ); + return (container, queue, reader); + } + + Iterable adds(ProviderContainer container) => container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .values + .map((intent) => intent.pending.op) + .whereType() + .where((op) => op.pagePublicId == 'page-3'); + + Set linksHere(ProviderContainer container) => + {for (final link in container.read(lineUpProvider).links) link.id}; + + Future send( + ProviderContainer container, { + required bool move, + }) => + container.read(strategyProvider.notifier).sendLineUpsToPage( + linkIds: {'link-a', 'link-b'}, + pageId: 'page-3', + move: move, + ); + + test('offers every other page', () async { + final (container, _, _) = await open(); + expect( + container.read(strategyProvider.notifier).lineUpPageTargets(), + [ + (id: 'page-1', name: 'Page 1', offset: -1), + (id: 'page-3', name: 'Page 3', offset: 1), + ], + ); + }); + + test('a copy is sent as a new group after the page\'s own', () async { + final (container, queue, _) = await open(); + + expect(await send(container, move: false), LineUpPageResult.done); + + final add = adds(container).single; + expect(add.sortIndex, 7); + final data = cloudPayloadData(add.payload); + expect(data['id'], add.lineupPublicId); + final links = _entries(data, 'links'); + expect(links.map((link) => link['name']), ['Bolt A', 'Bolt B']); + expect(_entries(data, 'origins'), hasLength(2)); + expect(_entries(data, 'landings'), hasLength(1)); + expect(links.map((link) => link['landingId']).toSet(), hasLength(1)); + final ids = { + add.lineupPublicId, + for (final field in ['origins', 'landings', 'links']) + for (final entry in _entries(data, field)) entry['id'], + }; + expect( + ids.intersection({ + 'link-a', + 'link-b', + 'stand-a', + 'stand-b', + 'land-ab', + 'link-there' + }), + isEmpty, + ); + expect(queue.flushNowCount, greaterThan(0)); + expect(linksHere(container), {'link-a', 'link-b', 'link-solo'}); + }); + + test('a move takes the lineups off this page once they are sent', () async { + final (container, _, _) = await open(); + + expect(await send(container, move: true), LineUpPageResult.done); + + expect(adds(container), hasLength(1)); + expect(linksHere(container), {'link-solo'}); + await _settle(); + }); + + test('a page that cannot be read gets nothing, and the lineups stay', + () async { + final (container, _, reader) = await open(); + reader.fails = true; + + expect(await send(container, move: true), LineUpPageResult.unreachable); + expect(adds(container), isEmpty); + expect(linksHere(container), {'link-a', 'link-b', 'link-solo'}); + }); + + test('lineups this device cannot store to send stay here', () async { + final (container, queue, _) = await open(); + queue.offCanvasStoreFails = true; + + expect(await send(container, move: true), LineUpPageResult.notSaved); + expect(linksHere(container), {'link-a', 'link-b', 'link-solo'}); + expect(container.read(strategySaveStateProvider).hasPendingCloudSync, + isFalse); + }); + + test( + 'the lineups are sent as they were when the user asked, and stay ' + 'here if they changed meanwhile', () async { + final (container, _, reader) = await open(); + final read = reader.gate = Completer(); + + final moved = send(container, move: true); + // While page 3 is read, a teammate's edit renames Bolt A. + final lineUps = container.read(lineUpProvider.notifier); + lineUps.mergeRemote(LineUpGraph( + origins: container.read(lineUpProvider).origins, + landings: container.read(lineUpProvider).landings, + links: [ + for (final link in container.read(lineUpProvider).links) + link.id == 'link-a' ? link.copyWith(name: 'Renamed') : link, + ], + )); + read.complete(); + + expect(await moved, LineUpPageResult.done); + expect( + _entries(cloudPayloadData(adds(container).single.payload), 'links') + .map((link) => link['name']), + ['Bolt A', 'Bolt B'], + ); + await _settle(); + }); + + test('a move finishing after a lineup went from this page leaves the rest', + () async { + final (container, _, reader) = await open(); + final read = reader.gate = Completer(); + + final moved = send(container, move: true); + // While page 3 is read, a teammate deletes Bolt B. + final now = container.read(lineUpProvider); + container.read(lineUpProvider.notifier).mergeRemote(LineUpGraph( + origins: now.origins, + landings: now.landings, + links: [ + for (final link in now.links) + if (link.id != 'link-b') link, + ], + )); + read.complete(); + + expect(await moved, LineUpPageResult.copiedInstead); + expect(adds(container), hasLength(1)); + expect(linksHere(container), {'link-a', 'link-solo'}); + await _settle(); + }); + }); } /// Reads cloud pages for a copy to another page. From 884de211b4220c25e8923b5758304286cf206b48 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 10 Oct 2026 00:14:26 -0400 Subject: [PATCH 2/7] Leave moved lineups here if they changed while being sent A move took the lineups off this page whenever they still existed after the other page was read, so a teammate's edit made meanwhile was deleted here while the other page got the older version. It now compares the lineups and their spots with what was sent and keeps changed ones here. Co-Authored-By: Claude Opus 5.5 --- lib/const/line_provider.dart | 60 ++++++++++++------- lib/providers/strategy_provider.dart | 11 +++- test/strategy_page_session_provider_test.dart | 42 ++++++++++++- 3 files changed, 86 insertions(+), 27 deletions(-) diff --git a/lib/const/line_provider.dart b/lib/const/line_provider.dart index 6053961e..2800e0ee 100644 --- a/lib/const/line_provider.dart +++ b/lib/const/line_provider.dart @@ -378,35 +378,28 @@ class LineUpGraph { const uuid = Uuid(); final newIds = {}; String renamed(String id) => newIds[id] ??= uuid.v4(); - final copied = [ - for (final link in links) - if (linkIds.contains(link.id)) link, - ]; - final originIds = {for (final link in copied) link.originId}; - final landingIds = {for (final link in copied) link.landingId}; + final part = linksWithSpots(linkIds); return LineUpGraph( origins: [ - for (final origin in origins) - if (originIds.contains(origin.id)) - LineUpOrigin( - id: renamed(origin.id), - agent: origin.agent - .deepCopy() - .copyWith(lineUpID: renamed(origin.id)), - ), + for (final origin in part.origins) + LineUpOrigin( + id: renamed(origin.id), + agent: origin.agent + .deepCopy() + .copyWith(lineUpID: renamed(origin.id)), + ), ], landings: [ - for (final landing in landings) - if (landingIds.contains(landing.id)) - LineUpLanding( - id: renamed(landing.id), - ability: landing.ability - .deepCopy() - .copyWith(lineUpID: renamed(landing.id)), - ), + for (final landing in part.landings) + LineUpLanding( + id: renamed(landing.id), + ability: landing.ability + .deepCopy() + .copyWith(lineUpID: renamed(landing.id)), + ), ], links: [ - for (final link in copied) + for (final link in part.links) link.deepCopy().copyWith( id: uuid.v4(), originId: renamed(link.originId), @@ -416,6 +409,27 @@ class LineUpGraph { ); } + /// The lineups [linkIds] and the spots they aim at. + LineUpGraph linksWithSpots(Set linkIds) { + final picked = [ + for (final link in links) + if (linkIds.contains(link.id)) link, + ]; + final originIds = {for (final link in picked) link.originId}; + final landingIds = {for (final link in picked) link.landingId}; + return LineUpGraph( + origins: [ + for (final origin in origins) + if (originIds.contains(origin.id)) origin, + ], + landings: [ + for (final landing in landings) + if (landingIds.contains(landing.id)) landing, + ], + links: picked, + ); + } + Map toJson() { return { 'lineUpOrigins': origins.map((origin) => origin.toJson()).toList(), diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 33b40354..cbfccf8b 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:developer'; import 'dart:io'; import 'dart:math' show max; @@ -1184,7 +1185,9 @@ class StrategyProvider extends Notifier { }) async { final strategyId = state.strategyId; final sourcePageId = ref.read(strategyPageSessionProvider).activePageId; - final copy = ref.read(lineUpProvider).graph.copyOfLinks(linkIds); + final graph = ref.read(lineUpProvider).graph; + final asked = jsonEncode(graph.linksWithSpots(linkIds).toJson()); + final copy = graph.copyOfLinks(linkIds); if (strategyId == null || sourcePageId == null || copy.links.isEmpty || @@ -1212,10 +1215,12 @@ class StrategyProvider extends Notifier { } if (placed != LineUpPageResult.done || !move) return placed; - final lineUps = ref.read(lineUpProvider); + // Only what was sent leaves: if the lineups or their spots changed + // meanwhile (a teammate's edit), the changed ones stay here. + final now = ref.read(lineUpProvider).graph.linksWithSpots(linkIds); if (state.strategyId != strategyId || ref.read(strategyPageSessionProvider).activePageId != sourcePageId || - !linkIds.every((id) => lineUps.linkById(id) != null)) { + jsonEncode(now.toJson()) != asked) { return LineUpPageResult.copiedInstead; } ref.read(lineUpProvider.notifier).deleteLinks(linkIds); diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 9bbf9ccd..00c185ed 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -10902,12 +10902,52 @@ void main() { )); read.complete(); - expect(await moved, LineUpPageResult.done); + // The copy is what the user asked to move; the renamed lineup stays. + expect(await moved, LineUpPageResult.copiedInstead); expect( _entries(cloudPayloadData(adds(container).single.payload), 'links') .map((link) => link['name']), ['Bolt A', 'Bolt B'], ); + expect( + { + for (final link in container.read(lineUpProvider).links) + link.id: link.name, + }, + {'link-a': 'Renamed', 'link-b': 'Bolt B', 'link-solo': ''}, + ); + await _settle(); + }); + + test('a move leaves the lineups here if a spot of theirs moved meanwhile', + () async { + final (container, _, reader) = await open(); + final read = reader.gate = Completer(); + + final moved = send(container, move: true); + // While page 3 is read, a teammate moves the landing both aim at. + final now = container.read(lineUpProvider); + container.read(lineUpProvider.notifier).mergeRemote(LineUpGraph( + origins: now.origins, + landings: [ + for (final landing in now.landings) + landing.id == 'land-ab' + ? landing.copyWith( + ability: landing.ability + .copyWith(position: const Offset(500, 500)), + ) + : landing, + ], + links: now.links, + )); + read.complete(); + + expect(await moved, LineUpPageResult.copiedInstead); + expect(linksHere(container), {'link-a', 'link-b', 'link-solo'}); + expect( + container.read(lineUpProvider).landingById('land-ab')!.ability.position, + const Offset(500, 500), + ); await _settle(); }); From 81da9ac5d2302c37fe521a80500e6d6fadd2e1d5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 10 Oct 2026 00:21:20 -0400 Subject: [PATCH 3/7] Say so when lineups can't be saved locally; ignore list order A local write that failed while putting lineups on another page threw past the menu, so the user saw a generic error rather than being told nothing was moved. It now reports notSaved. A move also compares the lineups it sent by id, so the same lineups redrawn in another order no longer count as changed. Co-Authored-By: Claude Opus 5.5 --- lib/providers/strategy_provider.dart | 34 ++++++++++++++++--- test/strategy_page_session_provider_test.dart | 21 ++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index cbfccf8b..62bde9b3 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1186,7 +1186,7 @@ class StrategyProvider extends Notifier { final strategyId = state.strategyId; final sourcePageId = ref.read(strategyPageSessionProvider).activePageId; final graph = ref.read(lineUpProvider).graph; - final asked = jsonEncode(graph.linksWithSpots(linkIds).toJson()); + final asked = _lineUpsAsSent(graph, linkIds); final copy = graph.copyOfLinks(linkIds); if (strategyId == null || sourcePageId == null || @@ -1217,22 +1217,41 @@ class StrategyProvider extends Notifier { // Only what was sent leaves: if the lineups or their spots changed // meanwhile (a teammate's edit), the changed ones stay here. - final now = ref.read(lineUpProvider).graph.linksWithSpots(linkIds); if (state.strategyId != strategyId || ref.read(strategyPageSessionProvider).activePageId != sourcePageId || - jsonEncode(now.toJson()) != asked) { + _lineUpsAsSent(ref.read(lineUpProvider).graph, linkIds) != asked) { return LineUpPageResult.copiedInstead; } ref.read(lineUpProvider.notifier).deleteLinks(linkIds); return LineUpPageResult.done; } + /// The lineups [linkIds] in [graph] and their spots, in an order that + /// does not depend on the graph's, to tell whether they changed. + static String _lineUpsAsSent(LineUpGraph graph, Set linkIds) { + final part = graph.linksWithSpots(linkIds); + int byId(Map a, Map b) => + (a['id'] as String).compareTo(b['id'] as String); + List> sorted(Iterable> json) => + json.toList()..sort(byId); + return jsonEncode({ + 'origins': sorted(part.origins.map((origin) => origin.toJson())), + 'landings': sorted(part.landings.map((landing) => landing.toJson())), + 'links': sorted(part.links.map((link) => link.toJson())), + }); + } + Future _addLineUpsToLocalPage({ required String strategyId, required String pageId, required LineUpGraph lineUps, }) async { - await _syncCurrentPageToHive(); + try { + await _syncCurrentPageToHive(); + } catch (error) { + log('Could not save page before putting lineups on $pageId: $error'); + return LineUpPageResult.notSaved; + } if (state.strategyId != strategyId) return LineUpPageResult.unavailable; final box = Hive.box(HiveBoxNames.strategiesBox); final strat = box.get(strategyId); @@ -1255,7 +1274,12 @@ class StrategyProvider extends Notifier { ], lastEdited: DateTime.now(), ); - await box.put(updated.id, updated); + try { + await box.put(updated.id, updated); + } catch (error) { + log('Could not put lineups on page $pageId: $error'); + return LineUpPageResult.notSaved; + } return LineUpPageResult.done; } diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 00c185ed..35d3e1be 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -10919,6 +10919,27 @@ void main() { await _settle(); }); + test('a move goes through when the lineups only came back in a new order', + () async { + final (container, _, reader) = await open(); + final read = reader.gate = Completer(); + + final moved = send(container, move: true); + // While page 3 is read, the page is drawn again from the same rows, + // in another order. + final now = container.read(lineUpProvider); + container.read(lineUpProvider.notifier).mergeRemote(LineUpGraph( + origins: now.origins.reversed.toList(), + landings: now.landings.reversed.toList(), + links: now.links.reversed.toList(), + )); + read.complete(); + + expect(await moved, LineUpPageResult.done); + expect(linksHere(container), {'link-solo'}); + await _settle(); + }); + test('a move leaves the lineups here if a spot of theirs moved meanwhile', () async { final (container, _, reader) = await open(); From c7fea296f08901e57ecc45fca1f9490506843c8a Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 10 Oct 2026 00:27:11 -0400 Subject: [PATCH 4/7] Tell a failed local lineup write by what the box holds Hive can store a write and then throw while compacting its file, so a thrown put does not mean the lineups are missing from the other page. The box decides: if the copy is there, the move or copy went through. Co-Authored-By: Claude Opus 5.5 --- lib/providers/strategy_provider.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 62bde9b3..f381ba8c 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1277,8 +1277,15 @@ class StrategyProvider extends Notifier { try { await box.put(updated.id, updated); } catch (error) { - log('Could not put lineups on page $pageId: $error'); - return LineUpPageResult.notSaved; + log('Writing lineups to page $pageId failed: $error'); + // Hive can store a write and then fail tidying its file, so what the + // box holds decides whether the lineups are there. + final copied = {for (final link in lineUps.links) link.id}; + final stored = box.get(strategyId)?.pages.where((p) => p.id == pageId); + final landed = stored != null && + stored.any((page) => + page.lineUpLinks.any((link) => copied.contains(link.id))); + if (!landed) return LineUpPageResult.notSaved; } return LineUpPageResult.done; } From 2fcaf69db9595f3d3c02bedcd309bac2000aa6a9 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 10 Oct 2026 00:34:18 -0400 Subject: [PATCH 5/7] Send a cloud lineup copy as one group, or nothing The lineups at one spot share it, so they always make one group row. A copy that would need more than one row is now refused before anything is queued, so a failure after the first row can no longer leave part of a copy on the other page while the toast says nothing was moved. Co-Authored-By: Claude Opus 5.5 --- lib/providers/strategy_provider.dart | 51 ++++++++++--------- test/strategy_page_session_provider_test.dart | 16 ++++++ 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index f381ba8c..2eb4bcc2 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1173,8 +1173,9 @@ class StrategyProvider extends Notifier { /// Puts a copy of the lineups [linkIds] on screen, and the spots they aim /// at, on page [pageId] under new ids, and for a [move] then takes them /// off this page as a delete would (spots another lineup here still uses - /// stay). The copies keep their media: an image is only cleaned up once - /// nothing in the strategy shows it. + /// stay). [linkIds] share a spot, as the menus pick them; on a cloud + /// strategy lineups that don't are not sent. The copies keep their media: + /// an image is only cleaned up once nothing in the strategy shows it. /// /// The lineups are taken as they are when the user asks. They go on the /// other page before they leave this one, so a failure never loses them. @@ -1290,7 +1291,7 @@ class StrategyProvider extends Notifier { return LineUpPageResult.done; } - /// Queues [lineUps] onto cloud page [pageId] as new lineup groups after + /// Queues [lineUps] onto cloud page [pageId] as one new lineup group after /// the page's own. The page is read from the server first, for where its /// groups end; if it cannot be read, nothing is queued. Future _addLineUpsToCloudPage({ @@ -1298,7 +1299,15 @@ class StrategyProvider extends Notifier { required String pageId, required LineUpGraph lineUps, }) async { - if (state.strategyId != strategyId) return LineUpPageResult.unavailable; + // The lineups at one spot share it, so they make one group, and the + // copy is one write that lands whole or not at all. Every id in it is + // new, so the group's id (its smallest lineup id) is one no row in the + // strategy has. + final rows = cloudLineupRows(lineUps).rows; + if (rows.length != 1 || state.strategyId != strategyId) { + return LineUpPageResult.unavailable; + } + final group = rows.single; final RemotePageSnapshot targetPage; try { targetPage = @@ -1312,26 +1321,20 @@ class StrategyProvider extends Notifier { } if (state.strategyId != strategyId) return LineUpPageResult.unavailable; - // Every id in the copy is new, so each group's id (its smallest lineup - // id) is one no row in the strategy has. - final rows = cloudLineupRows(lineUps).rows; - var sortIndex = - 1 + _cloudLineupSortIndexesOn(targetPage).fold(-1, max); - final queue = ref.read(strategyOpQueueProvider.notifier); - for (final row in rows) { - // The canvas never draws these: their page shows them from the server. - final queued = await queue.enqueueOffCanvas( - LineupAddOp( - opId: const Uuid().v4(), - lineupPublicId: row.publicId, - pagePublicId: pageId, - payload: row.payload, - sortIndex: sortIndex++, - ), - flushImmediately: true, - ); - if (!queued) return LineUpPageResult.notSaved; - } + // The canvas never draws the group: its page shows it from the server. + final queued = + await ref.read(strategyOpQueueProvider.notifier).enqueueOffCanvas( + LineupAddOp( + opId: const Uuid().v4(), + lineupPublicId: group.publicId, + pagePublicId: pageId, + payload: group.payload, + sortIndex: 1 + + _cloudLineupSortIndexesOn(targetPage).fold(-1, max), + ), + flushImmediately: true, + ); + if (!queued) return LineUpPageResult.notSaved; ref.read(strategySaveStateProvider.notifier) ..markDirty() ..setPendingCloudSync(true) diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 35d3e1be..0dd3b359 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -10919,6 +10919,22 @@ void main() { await _settle(); }); + test('lineups that share no spot are not sent, so none goes half-way', + () async { + final (container, _, _) = await open(); + + expect( + await container.read(strategyProvider.notifier).sendLineUpsToPage( + linkIds: {'link-a', 'link-solo'}, + pageId: 'page-3', + move: true, + ), + LineUpPageResult.unavailable, + ); + expect(adds(container), isEmpty); + expect(linksHere(container), {'link-a', 'link-b', 'link-solo'}); + }); + test('a move goes through when the lineups only came back in a new order', () async { final (container, _, reader) = await open(); From 38869ce4c07f4738da92d2707bd30c598bb342ea Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 10 Oct 2026 01:15:49 -0400 Subject: [PATCH 6/7] Copy lineups to the next or previous page, as placed items do Lineup menus offered Move to page and Copy to page with every page listed, unlike every other placed item. They now get the same "Copy to next page" and "Copy to previous page" items, in the same place and with the same toasts. A copied lineup and its spots get ids that carry the originals' (page_copy_id.dart), so a page that already has the lineup, or a copy of it, is not offered and gets no other, locally and on cloud. Co-Authored-By: Claude Opus 5.5 --- lib/const/line_provider.dart | 17 +- lib/const/page_copy_id.dart | 4 + lib/providers/strategy_provider.dart | 307 +++++++++--------- .../ability_visibility_context_menu.dart | 7 +- .../adjacent_page_copy_menu.dart | 34 +- .../agents/agent_widget.dart | 22 +- .../draggable_widgets/lineup_page_menu.dart | 101 ------ test/strategy_page_semantics_test.dart | 166 +++++----- test/strategy_page_session_provider_test.dart | 239 +++++--------- 9 files changed, 368 insertions(+), 529 deletions(-) delete mode 100644 lib/widgets/draggable_widgets/lineup_page_menu.dart diff --git a/lib/const/line_provider.dart b/lib/const/line_provider.dart index 2800e0ee..c8f5af24 100644 --- a/lib/const/line_provider.dart +++ b/lib/const/line_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/weapons.dart'; +import 'package:icarus/const/page_copy_id.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/action_provider.dart'; @@ -372,12 +373,12 @@ class LineUpGraph { ); } - /// The lineups [linkIds] and the spots they aim at, under new ids, ready - /// to be put on another page. Spots they share stay shared in the copy. + /// The lineups [linkIds] and the spots they aim at, ready to be put on + /// another page, each under a new id that carries its original's (see + /// page_copy_id.dart). Spots they share stay shared in the copy. LineUpGraph copyOfLinks(Set linkIds) { - const uuid = Uuid(); final newIds = {}; - String renamed(String id) => newIds[id] ??= uuid.v4(); + String renamed(String id) => newIds[id] ??= newPageCopyId(id); final part = linksWithSpots(linkIds); return LineUpGraph( origins: [ @@ -401,7 +402,7 @@ class LineUpGraph { links: [ for (final link in part.links) link.deepCopy().copyWith( - id: uuid.v4(), + id: renamed(link.id), originId: renamed(link.originId), landingId: renamed(link.landingId), ), @@ -1385,12 +1386,6 @@ class LineUpProvider extends Notifier { _recordDeletion(linkId, {linkId}); } - /// Removes [linkIds] as one deletion, for lineups moved to another page. - void deleteLinks(Set linkIds) { - if (linkIds.isEmpty) return; - _recordDeletion((linkIds.toList()..sort()).first, linkIds); - } - void deleteOrigin(String originId) { if (state.originById(originId) == null) return; _recordDeletion( diff --git a/lib/const/page_copy_id.dart b/lib/const/page_copy_id.dart index 2cb735ed..43f0ddad 100644 --- a/lib/const/page_copy_id.dart +++ b/lib/const/page_copy_id.dart @@ -9,6 +9,10 @@ import 'package:uuid/uuid.dart'; /// of the item first copied, and a copy of a copy keeps that root, so ids /// never nest. The transition pairs items by root when their ids differ. /// +/// A copied lineup and its spots get these ids on local strategies too: a +/// cloud lineup group needs an id no other group in the strategy has, and +/// one rule for both keeps a copied lineup the same wherever it lives. +/// /// Storage, ops, edits, deletes and undo use the full id. The root is only /// read to pair items across pages and to see whether a page already has one. const _copyMark = '~cp1~'; diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 2eb4bcc2..9f5eee4c 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:developer'; import 'dart:io'; import 'dart:math' show max; @@ -78,32 +77,6 @@ enum PageCopyResult { unavailable, } -/// A page lineups can be moved or copied to: every page but the one on -/// screen, in page order. [offset] is its distance from the page on screen, -/// so -1 is the previous page and 1 the next. -typedef LineUpPageTarget = ({String id, String name, int offset}); - -/// What became of moving or copying lineups to another page. -enum LineUpPageResult { - /// The lineups are on the other page, and, for a move, gone from this one. - done, - - /// A move put the lineups on the other page, but before they could be - /// taken off this one, the page on screen changed or one of them did (a - /// teammate's edit), so they are on both. - copiedInstead, - - /// The cloud page could not be read, so nothing was moved or copied. - unreachable, - - /// This device could not store the lineups to send, so nothing was moved - /// or copied. - notSaved, - - /// There was nothing to move or copy, or no such page. - unavailable, -} - class StrategyProvider extends Notifier { @override StrategyState build() { @@ -1124,37 +1097,40 @@ class StrategyProvider extends Notifier { return elements; } - /// The pages lineups on screen can be moved or copied to. - List lineUpPageTargets() { - final pages = _orderedPagesForLineUps(); + /// The pages next to the one on screen that the lineups [linkIds] can be + /// copied to, as for a placed item: a local neighbour that already has + /// one of them, or a copy of it, is left out. Only the page on screen of + /// a cloud strategy is read, so there the check waits for the copy. + List copyDirectionsForLineUps( + Set linkIds, + ) { + if (linkIds.isEmpty) return const []; + if (_currentStrategyIsCloud()) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || !_currentStrategyCanEditPages()) return const []; + return [ + for (final direction in PageTransitionDirection.values) + if (_adjacentCloudPageId(snapshot, direction) != null) direction, + ]; + } + final pages = _orderedLocalPages(); final currentIndex = pages.indexWhere( (page) => page.id == ref.read(strategyPageSessionProvider).activePageId, ); if (currentIndex < 0) return const []; + final roots = {for (final id in linkIds) pageCopyRoot(id)}; + bool has(StrategyPage page) => + page.lineUpLinks.any((link) => roots.contains(pageCopyRoot(link.id))); return [ - for (final (index, page) in pages.indexed) - if (index != currentIndex) - (id: page.id, name: page.name, offset: index - currentIndex), + if (currentIndex > 0 && !has(pages[currentIndex - 1])) + PageTransitionDirection.backward, + if (currentIndex < pages.length - 1 && !has(pages[currentIndex + 1])) + PageTransitionDirection.forward, ]; } - /// The strategy's pages in order, as ids and names, or none when lineups - /// cannot be sent to them. - List<({String id, String name})> _orderedPagesForLineUps() { - if (_currentStrategyIsCloud()) { - final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; - if (snapshot == null || - snapshot.header.publicId != state.strategyId || - !_currentStrategyCanEditPages()) { - return const []; - } - return [ - for (final page in [ - ...snapshot.pages - ]..sortBySortIndex((item) => item.sortIndex)) - (id: page.publicId, name: page.name), - ]; - } + /// The local strategy's pages in order, or none. + List _orderedLocalPages() { final strategyId = state.strategyId; if (strategyId == null || !Hive.isBoxOpen(HiveBoxNames.strategiesBox)) { return const []; @@ -1162,107 +1138,91 @@ class StrategyProvider extends Notifier { final strat = Hive.box(HiveBoxNames.strategiesBox).get(strategyId); if (strat == null) return const []; - return [ - for (final page in [ - ...strat.pages - ]..sortBySortIndex((item) => item.sortIndex)) - (id: page.id, name: page.name), - ]; - } - - /// Puts a copy of the lineups [linkIds] on screen, and the spots they aim - /// at, on page [pageId] under new ids, and for a [move] then takes them - /// off this page as a delete would (spots another lineup here still uses - /// stay). [linkIds] share a spot, as the menus pick them; on a cloud - /// strategy lineups that don't are not sent. The copies keep their media: - /// an image is only cleaned up once nothing in the strategy shows it. - /// - /// The lineups are taken as they are when the user asks. They go on the - /// other page before they leave this one, so a failure never loses them. - Future sendLineUpsToPage({ + return [...strat.pages]..sortBySortIndex((item) => item.sortIndex); + } + + /// Copies the lineups [linkIds] on screen, and the spots they aim at, to + /// the next or previous page, as copying a placed item does. Each copied + /// lineup and spot gets an id that carries the original's (see + /// page_copy_id.dart), so a page that already has one of them, or a copy + /// of it, gets no other. [linkIds] share a spot, as the menus pick them; + /// on a cloud strategy lineups that don't are not copied. The copies keep + /// their media: an image is only cleaned up once nothing in the strategy + /// shows it. The lineups are taken as they are when the user asks. + Future copyLineUpsToAdjacentPage({ required Set linkIds, - required String pageId, - required bool move, + required PageTransitionDirection direction, }) async { final strategyId = state.strategyId; - final sourcePageId = ref.read(strategyPageSessionProvider).activePageId; - final graph = ref.read(lineUpProvider).graph; - final asked = _lineUpsAsSent(graph, linkIds); - final copy = graph.copyOfLinks(linkIds); - if (strategyId == null || - sourcePageId == null || - copy.links.isEmpty || - !lineUpPageTargets().any((page) => page.id == pageId)) { - return LineUpPageResult.unavailable; + final copy = ref.read(lineUpProvider).graph.copyOfLinks(linkIds); + if (strategyId == null || copy.links.isEmpty) { + return PageCopyResult.unavailable; } - - final LineUpPageResult placed; if (_currentStrategyIsCloud()) { - final queued = _cloudCopies.then( - (_) => _addLineUpsToCloudPage( + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + final pageId = + snapshot == null ? null : _adjacentCloudPageId(snapshot, direction); + if (pageId == null || !_currentStrategyCanEditPages()) { + return PageCopyResult.unavailable; + } + final result = _cloudCopies.then( + (_) => _copyLineUpsToCloudPage( strategyId: strategyId, pageId: pageId, lineUps: copy, ), ); - _cloudCopies = queued.then((_) {}, onError: (_) {}); - placed = await queued; - } else { - placed = await _addLineUpsToLocalPage( - strategyId: strategyId, - pageId: pageId, - lineUps: copy, - ); + _cloudCopies = result.then((_) {}, onError: (_) {}); + return result; } - if (placed != LineUpPageResult.done || !move) return placed; - - // Only what was sent leaves: if the lineups or their spots changed - // meanwhile (a teammate's edit), the changed ones stay here. - if (state.strategyId != strategyId || - ref.read(strategyPageSessionProvider).activePageId != sourcePageId || - _lineUpsAsSent(ref.read(lineUpProvider).graph, linkIds) != asked) { - return LineUpPageResult.copiedInstead; - } - ref.read(lineUpProvider.notifier).deleteLinks(linkIds); - return LineUpPageResult.done; - } - - /// The lineups [linkIds] in [graph] and their spots, in an order that - /// does not depend on the graph's, to tell whether they changed. - static String _lineUpsAsSent(LineUpGraph graph, Set linkIds) { - final part = graph.linksWithSpots(linkIds); - int byId(Map a, Map b) => - (a['id'] as String).compareTo(b['id'] as String); - List> sorted(Iterable> json) => - json.toList()..sort(byId); - return jsonEncode({ - 'origins': sorted(part.origins.map((origin) => origin.toJson())), - 'landings': sorted(part.landings.map((landing) => landing.toJson())), - 'links': sorted(part.links.map((link) => link.toJson())), - }); + return _copyLineUpsToLocalPage( + strategyId: strategyId, + direction: direction, + lineUps: copy, + ); } - Future _addLineUpsToLocalPage({ + /// Whether [links] holds one of [lineUps]' lineups, or a copy of it. + static bool _hasAnyOf(Iterable links, LineUpGraph lineUps) { + final roots = {for (final link in lineUps.links) pageCopyRoot(link.id)}; + return links.any((id) => roots.contains(pageCopyRoot(id))); + } + + Future _copyLineUpsToLocalPage({ required String strategyId, - required String pageId, + required PageTransitionDirection direction, required LineUpGraph lineUps, }) async { try { await _syncCurrentPageToHive(); } catch (error) { - log('Could not save page before putting lineups on $pageId: $error'); - return LineUpPageResult.notSaved; + log('Could not save the page before copying lineups: $error'); + return PageCopyResult.notSaved; } - if (state.strategyId != strategyId) return LineUpPageResult.unavailable; + if (state.strategyId != strategyId) return PageCopyResult.unavailable; + final pages = _orderedLocalPages(); + final currentIndex = pages.indexWhere( + (page) => page.id == ref.read(strategyPageSessionProvider).activePageId, + ); + final targetIndex = switch (direction) { + PageTransitionDirection.backward => currentIndex - 1, + PageTransitionDirection.forward => currentIndex + 1, + }; + if (currentIndex < 0 || targetIndex < 0 || targetIndex >= pages.length) { + return PageCopyResult.unavailable; + } + final target = pages[targetIndex]; + if (_hasAnyOf(target.lineUpLinks.map((link) => link.id), lineUps)) { + return PageCopyResult.alreadyThere; + } + final box = Hive.box(HiveBoxNames.strategiesBox); final strat = box.get(strategyId); - if (strat == null || !strat.pages.any((page) => page.id == pageId)) { - return LineUpPageResult.unavailable; - } + if (strat == null) return PageCopyResult.unavailable; final updated = strat.copyWith( pages: [ for (final page in strat.pages) - if (page.id == pageId) + if (page.id == target.id) page.copyWith( lineUpGraph: LineUpGraph( origins: [...page.lineUpOrigins, ...lineUps.origins], @@ -1278,36 +1238,50 @@ class StrategyProvider extends Notifier { try { await box.put(updated.id, updated); } catch (error) { - log('Writing lineups to page $pageId failed: $error'); + log('Writing lineups to page ${target.id} failed: $error'); // Hive can store a write and then fail tidying its file, so what the // box holds decides whether the lineups are there. - final copied = {for (final link in lineUps.links) link.id}; - final stored = box.get(strategyId)?.pages.where((p) => p.id == pageId); + final stored = box.get(strategyId)?.pages.where((p) => p.id == target.id); final landed = stored != null && - stored.any((page) => - page.lineUpLinks.any((link) => copied.contains(link.id))); - if (!landed) return LineUpPageResult.notSaved; + stored.any((page) => page.lineUpLinks.any( + (link) => lineUps.links.any((copied) => copied.id == link.id))); + if (!landed) return PageCopyResult.notSaved; } - return LineUpPageResult.done; + return PageCopyResult.copied; } /// Queues [lineUps] onto cloud page [pageId] as one new lineup group after - /// the page's own. The page is read from the server first, for where its - /// groups end; if it cannot be read, nothing is queued. - Future _addLineUpsToCloudPage({ + /// the page's own. The page is read from the server first, with the work + /// still queued for it laid over: if it has one of the lineups, or a copy + /// of it, nothing is queued, and if it cannot be read, neither. + Future _copyLineUpsToCloudPage({ required String strategyId, required String pageId, required LineUpGraph lineUps, }) async { // The lineups at one spot share it, so they make one group, and the - // copy is one write that lands whole or not at all. Every id in it is - // new, so the group's id (its smallest lineup id) is one no row in the - // strategy has. + // copy is one write that lands whole or not at all. Its ids are new, so + // the group's id (its smallest lineup id) is one no row has. final rows = cloudLineupRows(lineUps).rows; - if (rows.length != 1 || state.strategyId != strategyId) { - return LineUpPageResult.unavailable; + final accountId = ref.read(strategyOpQueueProvider).accountId; + if (rows.length != 1 || + accountId == null || + state.strategyId != strategyId) { + return PageCopyResult.unavailable; } final group = rows.single; + // Hive refuses keys over 255 characters, and an op is stored under one + // naming its account, strategy, page and group. Only a lineup imported + // with an unusually long id could get here. + if (DurableOutboxRecord.createStorageKey( + accountId: accountId, + strategyPublicId: strategyId, + entityKey: EntitySyncKey.lineup(pageId, group.publicId), + ).length > + 255) { + return PageCopyResult.unavailable; + } + final RemotePageSnapshot targetPage; try { targetPage = @@ -1316,10 +1290,19 @@ class StrategyProvider extends Notifier { pagePublicId: pageId, ); } catch (error) { - log('Could not read page $pageId to put lineups on it: $error'); - return LineUpPageResult.unreachable; + log('Could not read page $pageId to copy lineups onto it: $error'); + return PageCopyResult.unreachable; } - if (state.strategyId != strategyId) return LineUpPageResult.unavailable; + if (state.strategyId != strategyId) return PageCopyResult.unavailable; + + final onTarget = _cloudLineupGroupsOn(targetPage); + final linksThere = [ + for (final payload in onTarget.values.map((group) => group.payload)) + if (payload != null) + for (final link in cloudPayloadData(payload)['links'] as List? ?? []) + if (link is Map && link['id'] is String) link['id'] as String, + ]; + if (_hasAnyOf(linksThere, lineUps)) return PageCopyResult.alreadyThere; // The canvas never draws the group: its page shows it from the server. final queued = @@ -1330,27 +1313,37 @@ class StrategyProvider extends Notifier { pagePublicId: pageId, payload: group.payload, sortIndex: 1 + - _cloudLineupSortIndexesOn(targetPage).fold(-1, max), + onTarget.values + .map((group) => group.sortIndex) + .fold(-1, max), ), flushImmediately: true, ); - if (!queued) return LineUpPageResult.notSaved; + if (!queued) return PageCopyResult.notSaved; ref.read(strategySaveStateProvider.notifier) ..markDirty() ..setPendingCloudSync(true) ..setCloudSyncError(null); - return LineUpPageResult.done; + return PageCopyResult.copied; } - /// The sortIndexes of the lineup groups on [page] as the server has them, - /// with the work still queued for it laid over. - Iterable _cloudLineupSortIndexesOn(RemotePageSnapshot page) { + /// The lineup groups on [page] as the server has them, with the work still + /// queued for it laid over, refused work waiting for the user's choice + /// included: by id, their sortIndexes and what they hold. + Map _cloudLineupGroupsOn( + RemotePageSnapshot page, + ) { final pageId = page.page.publicId; - final groups = { + final groups = { for (final lineup in page.lineups) - if (!lineup.deleted) lineup.publicId: lineup.sortIndex, + if (!lineup.deleted) + lineup.publicId: ( + sortIndex: lineup.sortIndex, + payload: lineup.payload, + ), }; final queue = ref.read(strategyOpQueueProvider); + // Later entries win: a sent op over a paused one, its successor over it. for (final pendingByKey in [ queue.attentionByEntityKey.map((k, v) => MapEntry(k, v.pending)), queue.pausedByEntityKey.map((k, v) => MapEntry(k, v.pending)), @@ -1362,15 +1355,19 @@ class StrategyProvider extends Notifier { if (key.kind != EntitySyncKeyKind.lineup || key.pageId != pageId) { return; } + final id = key.entityId!; switch (pending.op) { case LineupDeleteOp(): - groups.remove(key.entityId); + groups.remove(id); case final op: - groups[key.entityId!] = op.sortIndex ?? groups[key.entityId!] ?? 0; + groups[id] = ( + sortIndex: op.sortIndex ?? groups[id]?.sortIndex ?? 0, + payload: op.payload as CloudPayload? ?? groups[id]?.payload, + ); } }); } - return groups.values; + return groups; } static StrategyPage? _copyPlacedWidgetBetweenPages({ diff --git a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart index 20fbb192..c326b01e 100644 --- a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart +++ b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart @@ -16,7 +16,6 @@ import 'package:icarus/widgets/draggable_widgets/adjacent_page_copy_menu.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:icarus/widgets/draggable_widgets/lineup_page_menu.dart'; bool supportsAbilityVisibilityMenu(Ability? ability) { return supportsAbilityInactiveState(ability) || @@ -53,7 +52,10 @@ List? buildAbilityContextMenuItems( ); final adjacentPageItems = landingId == null ? buildAdjacentPageCopyMenuItems(ref, ability.id) - : const []; + : buildLineUpAdjacentPageCopyMenuItems(ref, { + for (final link in ref.read(lineUpProvider).linksToLanding(landingId)) + link.id, + }); final lineUpItems = landingId == null ? const [] : buildLandingLineUpMenuItems(ref, landingId, context: context); @@ -123,7 +125,6 @@ List buildLandingLineUpMenuItems( .editLineUpPlacement(links.first.id); }, ), - ...buildLineUpPageMenuItems(ref, links), ShadContextMenuItem( leading: Icon( LucideIcons.trash2, diff --git a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart index ee18d4a2..f4b10dd4 100644 --- a/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart +++ b/lib/widgets/draggable_widgets/adjacent_page_copy_menu.dart @@ -10,15 +10,39 @@ List buildAdjacentPageCopyMenuItems( String widgetId, ) { if (widgetId.isEmpty) return const []; + final notifier = ref.read(strategyProvider.notifier); + return _copyMenuItems( + notifier.copyDirectionsForPlacedWidget(widgetId), + (direction) => notifier.copyPlacedWidgetToAdjacentPage( + widgetId: widgetId, + direction: direction, + ), + ); +} +/// The same items for the lineups [linkIds]: every lineup at the spot the +/// user right-clicked, copied together with the spots they aim at. +List buildLineUpAdjacentPageCopyMenuItems( + WidgetRef ref, + Set linkIds, +) { + if (linkIds.isEmpty) return const []; final notifier = ref.read(strategyProvider.notifier); - final directions = notifier.copyDirectionsForPlacedWidget(widgetId); + return _copyMenuItems( + notifier.copyDirectionsForLineUps(linkIds), + (direction) => notifier.copyLineUpsToAdjacentPage( + linkIds: linkIds, + direction: direction, + ), + ); +} +List _copyMenuItems( + List directions, + Future Function(PageTransitionDirection direction) copyTo, +) { Future copy(PageTransitionDirection direction) async { - final result = await notifier.copyPlacedWidgetToAdjacentPage( - widgetId: widgetId, - direction: direction, - ); + final result = await copyTo(direction); final page = direction == PageTransitionDirection.forward ? 'next page' : 'previous page'; diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index fd670e4d..d6486bcf 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -31,7 +31,6 @@ import 'package:icarus/widgets/mouse_watch.dart'; import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:icarus/widgets/draggable_widgets/lineup_page_menu.dart'; /// Grayscale color matrix for dead agents const List _identityColorMatrix = [ @@ -256,10 +255,18 @@ class AgentWidget extends ConsumerWidget { ), isAttack: mapState.isAttack, ); - final adjacentPageCopyItems = - canInteract && lineUpId == null && placedAgentNode != null - ? buildAdjacentPageCopyMenuItems(ref, placedAgentNode.id) - : const []; + final adjacentPageCopyItems = !canInteract + ? const [] + : lineUpId != null + ? buildLineUpAdjacentPageCopyMenuItems(ref, { + for (final link in ref + .read(lineUpProvider.notifier) + .linksFromOrigin(lineUpId!)) + link.id, + }) + : placedAgentNode != null + ? buildAdjacentPageCopyMenuItems(ref, placedAgentNode.id) + : const []; final hasContextMenuItemsBelow = canInteract && (lineUpId != null || (plainAgent != null && plainAgent.id.isNotEmpty) || @@ -316,11 +323,6 @@ class AgentWidget extends ConsumerWidget { .editLineUpPlacement(links.first.id); }, ), - if (canInteract && lineUpId != null) - ...buildLineUpPageMenuItems( - ref, - ref.read(lineUpProvider.notifier).linksFromOrigin(lineUpId!), - ), if (canInteract && lineUpId != null) ShadContextMenuItem( leading: Icon( diff --git a/lib/widgets/draggable_widgets/lineup_page_menu.dart b/lib/widgets/draggable_widgets/lineup_page_menu.dart deleted file mode 100644 index ce33d030..00000000 --- a/lib/widgets/draggable_widgets/lineup_page_menu.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/const/line_provider.dart'; -import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/strategy_provider.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; - -/// "Move to page" and "Copy to page" for the lineups [links] (every lineup -/// at the spot the user right-clicked), each listing the strategy's other -/// pages. A spot one of them shares with a lineup that stays is copied, so -/// that lineup keeps it. -List buildLineUpPageMenuItems( - WidgetRef ref, - List links, -) { - if (links.isEmpty) return const []; - final notifier = ref.read(strategyProvider.notifier); - final pages = notifier.lineUpPageTargets(); - if (pages.isEmpty) return const []; - - final linkIds = {for (final link in links) link.id}; - final what = links.length > 1 - ? '${links.length} lineups' - : links.single.name.trim().isEmpty - ? 'the lineup' - : '“${links.single.name.trim()}”'; - - Future send(LineUpPageTarget page, {required bool move}) async { - final result = await notifier.sendLineUpsToPage( - linkIds: linkIds, - pageId: page.id, - move: move, - ); - final nothing = move ? 'nothing was moved' : 'nothing was copied'; - switch (result) { - case LineUpPageResult.done: - Settings.showToast( - message: '${move ? 'Moved' : 'Copied'} $what to ${page.name}.', - backgroundColor: Settings.tacticalVioletTheme.primary, - ); - case LineUpPageResult.copiedInstead: - Settings.showToast( - message: 'Copied $what to ${page.name}. This page changed ' - 'meanwhile, so it is still here too.', - backgroundColor: Settings.tacticalVioletTheme.primary, - ); - case LineUpPageResult.unreachable: - Settings.showToast( - message: "Couldn't reach the cloud, so $nothing.", - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - case LineUpPageResult.notSaved: - Settings.showToast( - message: "Couldn't save the change on this device, so $nothing.", - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - case LineUpPageResult.unavailable: - break; - } - } - - List pageItems({required bool move}) => [ - for (final page in pages) - ShadContextMenuItem( - trailing: page.offset.abs() == 1 - ? Text( - page.offset < 0 ? 'previous' : 'next', - style: TextStyle( - fontSize: 12, - color: Settings.tacticalVioletTheme.mutedForeground, - ), - ) - : null, - onPressed: () => send(page, move: move), - child: Text(page.name), - ), - ]; - - return [ - ShadContextMenuItem( - leading: const Icon(LucideIcons.fileInput, size: 16), - trailing: const Icon(LucideIcons.chevronRight, size: 16), - items: pageItems(move: true), - child: Text( - links.length > 1 - ? 'Move ${links.length} lineups to page' - : 'Move to page', - ), - ), - ShadContextMenuItem( - leading: const Icon(LucideIcons.copyPlus, size: 16), - trailing: const Icon(LucideIcons.chevronRight, size: 16), - items: pageItems(move: false), - child: Text( - links.length > 1 - ? 'Copy ${links.length} lineups to page' - : 'Copy to page', - ), - ), - ]; -} diff --git a/test/strategy_page_semantics_test.dart b/test/strategy_page_semantics_test.dart index 7df738fe..47a7751c 100644 --- a/test/strategy_page_semantics_test.dart +++ b/test/strategy_page_semantics_test.dart @@ -9,13 +9,13 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/page_copy_id.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/migrations/page_name_provenance_migration.dart'; -import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; @@ -330,27 +330,27 @@ void main() { }, ); - group('moving and copying lineups to another page', () { + group('copying lineups to the next or previous page', () { // Sova stands at two spots. Two lineups from them meet at one landing; // a third goes from the first spot to another landing. - LineUpGraph sovaLineUps() { + LineUpGraph sovaLineUps({String prefix = ''}) { final ability = AgentData.agents[AgentType.sova]!.abilities.first; LineUpOrigin origin(String id, Offset at) => LineUpOrigin( - id: id, + id: '$prefix$id', agent: PlacedAgent( - id: 'agent-$id', + id: 'agent-$prefix$id', type: AgentType.sova, position: at, - lineUpID: id, + lineUpID: '$prefix$id', ), ); LineUpLanding landing(String id, Offset at) => LineUpLanding( - id: id, + id: '$prefix$id', ability: PlacedAbility( - id: 'ability-$id', + id: 'ability-$prefix$id', data: ability, position: at, - lineUpID: id, + lineUpID: '$prefix$id', ), ); return LineUpGraph( @@ -364,23 +364,23 @@ void main() { ], links: [ LineUpLink( - id: 'bolt-a', - originId: 'stand-1', - landingId: 'land-1', + id: '${prefix}bolt-a', + originId: '${prefix}stand-1', + landingId: '${prefix}land-1', name: 'Bolt A', notes: 'Jump throw', images: [SimpleImageData(id: 'shot-a', fileExtension: '.png')], ), LineUpLink( - id: 'bolt-b', - originId: 'stand-2', - landingId: 'land-1', + id: '${prefix}bolt-b', + originId: '${prefix}stand-2', + landingId: '${prefix}land-1', name: 'Bolt B', ), LineUpLink( - id: 'recon', - originId: 'stand-1', - landingId: 'land-2', + id: '${prefix}recon', + originId: '${prefix}stand-1', + landingId: '${prefix}land-2', name: 'Recon', ), ], @@ -388,7 +388,7 @@ void main() { } Future open({ - LineUpGraph targetLineUps = LineUpGraph.empty, + LineUpGraph nextPageLineUps = LineUpGraph.empty, }) async { final pages = [ _page(id: 'page-1', name: 'Page 1', sortIndex: 0), @@ -398,12 +398,11 @@ void main() { sortIndex: 1, lineUps: sovaLineUps(), ), - _page(id: 'page-3', name: 'Page 3', sortIndex: 2), _page( - id: 'page-4', - name: 'Retake', - sortIndex: 3, - lineUps: targetLineUps, + id: 'page-3', + name: 'Page 3', + sortIndex: 2, + lineUps: nextPageLineUps, ), ]; final strategy = _strategy(pages); @@ -420,65 +419,50 @@ void main() { .singleWhere((page) => page.id == pageId) .lineUpGraph; - test('every other page is offered, in order', () async { - final container = await open(); - expect( - container.read(strategyProvider.notifier).lineUpPageTargets(), - [ - (id: 'page-1', name: 'Page 1', offset: -1), - (id: 'page-3', name: 'Page 3', offset: 1), - (id: 'page-4', name: 'Retake', offset: 2), - ], - ); - }); + Future copyBolts( + ProviderContainer container, + PageTransitionDirection direction, + ) => + container.read(strategyProvider.notifier).copyLineUpsToAdjacentPage( + linkIds: {'bolt-a', 'bolt-b'}, + direction: direction, + ); test( - 'a move puts the lineups on the page under new ids and leaves a ' - 'spot another lineup here still uses', () async { + 'lineups at a spot copy to the next page with ids that carry their ' + 'originals', () async { final container = await open(); - expect( - await container.read(strategyProvider.notifier).sendLineUpsToPage( - linkIds: {'bolt-a', 'bolt-b'}, - pageId: 'page-4', - move: true, - ), - LineUpPageResult.done, + container + .read(strategyProvider.notifier) + .copyDirectionsForLineUps({'bolt-a', 'bolt-b'}), + [PageTransitionDirection.backward, PageTransitionDirection.forward], ); - // This page keeps Recon and the spot it stands at. - final here = container.read(lineUpProvider); - expect(here.links.map((link) => link.id), ['recon']); - expect(here.origins.map((origin) => origin.id), ['stand-1']); - expect(here.landings.map((landing) => landing.id), ['land-2']); + expect( + await copyBolts(container, PageTransitionDirection.forward), + PageCopyResult.copied, + ); - // The other page has both lineups, still meeting at one landing, with + // The next page has both lineups, still meeting at one landing, with // their names, notes and screenshots, where they were. - final there = savedLineUps('page-4'); + final there = savedLineUps('page-3'); expect(there.links.map((link) => link.name), ['Bolt A', 'Bolt B']); - expect(there.origins, hasLength(2)); - expect(there.landings, hasLength(1)); - final ids = { - for (final origin in there.origins) origin.id, - for (final landing in there.landings) landing.id, - for (final link in there.links) link.id, - }; - expect(ids, hasLength(5)); expect( - ids.intersection( - {'stand-1', 'stand-2', 'land-1', 'land-2', 'bolt-a', 'bolt-b'}, - ), - isEmpty, + there.links.map((link) => pageCopyRoot(link.id)), + ['bolt-a', 'bolt-b'], ); expect( - there.links.map((link) => link.landingId).toSet(), - {there.landings.single.id}, + there.origins.map((origin) => pageCopyRoot(origin.id)), + ['stand-1', 'stand-2'], ); + expect(pageCopyRoot(there.landings.single.id), 'land-1'); expect( - there.links.map((link) => link.originId).toSet(), - {for (final origin in there.origins) origin.id}, + there.links.map((link) => link.landingId).toSet(), + {there.landings.single.id}, ); for (final origin in there.origins) { + expect(origin.id, isNot(pageCopyRoot(origin.id))); expect(origin.agent.lineUpID, origin.id); } expect(there.landings.single.ability.lineUpID, there.landings.single.id); @@ -493,43 +477,51 @@ void main() { .position, const Offset(10, 10), ); + // This page is left as it was. + expect(container.read(lineUpProvider).links, hasLength(3)); }); - test('undo after a move brings the lineups back to this page', () async { + test( + 'a page that already has the lineups, or copies of them, is not ' + 'offered and gets no others', () async { final container = await open(); - await container.read(strategyProvider.notifier).sendLineUpsToPage( - linkIds: {'bolt-a', 'bolt-b'}, - pageId: 'page-4', - move: true, - ); - - container.read(actionProvider.notifier).undoAction(); + final notifier = container.read(strategyProvider.notifier); + await copyBolts(container, PageTransitionDirection.forward); + expect(notifier.copyDirectionsForLineUps({'bolt-a', 'bolt-b'}), [ + PageTransitionDirection.backward, + ]); expect( - container.read(lineUpProvider).links.map((link) => link.id).toSet(), - {'bolt-a', 'bolt-b', 'recon'}, + await copyBolts(container, PageTransitionDirection.forward), + PageCopyResult.alreadyThere, ); + expect(savedLineUps('page-3').links, hasLength(2)); + // Another lineup can still go. + expect(notifier.copyDirectionsForLineUps({'recon'}), [ + PageTransitionDirection.backward, + PageTransitionDirection.forward, + ]); }); - test('a copy leaves this page as it was and adds to what is there', - () async { - final container = await open(targetLineUps: sovaLineUps()); + test('a copy adds to the lineups already on the page', () async { + final container = + await open(nextPageLineUps: sovaLineUps(prefix: 'other-')); expect( - await container.read(strategyProvider.notifier).sendLineUpsToPage( + await container + .read(strategyProvider.notifier) + .copyLineUpsToAdjacentPage( linkIds: {'recon'}, - pageId: 'page-4', - move: false, + direction: PageTransitionDirection.forward, ), - LineUpPageResult.done, + PageCopyResult.copied, ); - expect(container.read(lineUpProvider).links, hasLength(3)); - final there = savedLineUps('page-4'); + final there = savedLineUps('page-3'); expect(there.links.map((link) => link.name), ['Bolt A', 'Bolt B', 'Recon', 'Recon']); final copy = there.links.last; - expect(copy.id, isNot('recon')); + expect(pageCopyRoot(copy.id), 'recon'); expect( there.origins .singleWhere((origin) => origin.id == copy.originId) diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 0dd3b359..d204bfef 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -6800,12 +6800,13 @@ void main() { // The copy is sent, and its answer held back. final hold = repository.hold = Completer(); expect( - await container.read(strategyProvider.notifier).sendLineUpsToPage( + await container + .read(strategyProvider.notifier) + .copyLineUpsToAdjacentPage( linkIds: {'link-a'}, - pageId: target.publicId, - move: false, + direction: PageTransitionDirection.forward, ), - LineUpPageResult.done, + PageCopyResult.copied, ); await _until(() => batches.isNotEmpty); final groupId = (batches.single.single as LineupAddOp).lineupPublicId; @@ -10737,7 +10738,7 @@ void main() { }); }); - group('moving and copying lineups to another cloud page', () { + group('copying lineups to the next or previous cloud page', () { final pages = [_page('page-1', 0), _page('page-2', 1), _page('page-3', 2)]; // On page 2, lineups a and b meet at one landing; solo is on its own. @@ -10754,10 +10755,10 @@ void main() { ], ); - /// Opens the strategy on page 2; the repository reads page 3 with a - /// lineup group of its own at sortIndex 6. - Future<(ProviderContainer, _FakeStrategyOpQueueNotifier, _PageReader)> - open() async { + /// Opens the strategy on page 2; the repository reads page 3 with + /// [nextPage] on it. + Future<(ProviderContainer, _FakeStrategyOpQueueNotifier, _PageReader)> open( + {List? nextPage}) async { final onScreen = _pageSnapshot( pages[1], lineups: [shared, _lineup('page-2', 'solo', sortIndex: 1)], @@ -10766,7 +10767,7 @@ void main() { final reader = _PageReader({ 'page-3': _pageSnapshot( pages[2], - lineups: [_lineup('page-3', 'there', sortIndex: 6)], + lineups: nextPage ?? [_lineup('page-3', 'there', sortIndex: 6)], ), }); final container = await _cloudContainer( @@ -10798,31 +10799,28 @@ void main() { Set linksHere(ProviderContainer container) => {for (final link in container.read(lineUpProvider).links) link.id}; - Future send( - ProviderContainer container, { - required bool move, - }) => - container.read(strategyProvider.notifier).sendLineUpsToPage( + Future copyBolts(ProviderContainer container) => + container.read(strategyProvider.notifier).copyLineUpsToAdjacentPage( linkIds: {'link-a', 'link-b'}, - pageId: 'page-3', - move: move, + direction: PageTransitionDirection.forward, ); - test('offers every other page', () async { + test('offers both neighbours', () async { final (container, _, _) = await open(); expect( - container.read(strategyProvider.notifier).lineUpPageTargets(), - [ - (id: 'page-1', name: 'Page 1', offset: -1), - (id: 'page-3', name: 'Page 3', offset: 1), - ], + container + .read(strategyProvider.notifier) + .copyDirectionsForLineUps({'link-a', 'link-b'}), + [PageTransitionDirection.forward, PageTransitionDirection.backward], ); }); - test('a copy is sent as a new group after the page\'s own', () async { + test( + 'a copy is sent as one group after the page\'s own, with ids that ' + 'carry the originals\'', () async { final (container, queue, _) = await open(); - expect(await send(container, move: false), LineUpPageResult.done); + expect(await copyBolts(container), PageCopyResult.copied); final add = adds(container).single; expect(add.sortIndex, 7); @@ -10830,185 +10828,112 @@ void main() { expect(data['id'], add.lineupPublicId); final links = _entries(data, 'links'); expect(links.map((link) => link['name']), ['Bolt A', 'Bolt B']); - expect(_entries(data, 'origins'), hasLength(2)); - expect(_entries(data, 'landings'), hasLength(1)); - expect(links.map((link) => link['landingId']).toSet(), hasLength(1)); - final ids = { - add.lineupPublicId, - for (final field in ['origins', 'landings', 'links']) - for (final entry in _entries(data, field)) entry['id'], - }; expect( - ids.intersection({ - 'link-a', - 'link-b', - 'stand-a', - 'stand-b', - 'land-ab', - 'link-there' - }), - isEmpty, + links.map((link) => pageCopyRoot(link['id'] as String)), + ['link-a', 'link-b'], + ); + expect( + _entries(data, 'origins').map((o) => pageCopyRoot(o['id'] as String)), + ['stand-a', 'stand-b'], ); + final landing = _entries(data, 'landings').single; + expect(pageCopyRoot(landing['id'] as String), 'land-ab'); + expect(links.map((link) => link['landingId']).toSet(), {landing['id']}); + expect(add.lineupPublicId, isNot('link-a')); expect(queue.flushNowCount, greaterThan(0)); expect(linksHere(container), {'link-a', 'link-b', 'link-solo'}); }); - test('a move takes the lineups off this page once they are sent', () async { - final (container, _, _) = await open(); + test('a page that already has a copy of the lineups gets no other', + () async { + final copyId = 'link-b~cp1~${const Uuid().v4()}'; + final (container, _, _) = await open(nextPage: [ + _groupRow( + 'page-3', + copyId, + origins: [_originJson('stand-copy')], + landings: [_landingJson('land-copy')], + links: [ + _linkJson(copyId, originId: 'stand-copy', landingId: 'land-copy'), + ], + ), + ]); - expect(await send(container, move: true), LineUpPageResult.done); + expect(await copyBolts(container), PageCopyResult.alreadyThere); + expect(adds(container), isEmpty); + }); - expect(adds(container), hasLength(1)); - expect(linksHere(container), {'link-solo'}); + test('copying twice at once sends one copy', () async { + final (container, queue, _) = await open(); + + // The first copy's write to the outbox takes a while. + final write = queue.writeGate = Completer(); + final copies = Future.wait([copyBolts(container), copyBolts(container)]); await _settle(); + queue.writeGate = null; + write.complete(); + + expect( + await copies, [PageCopyResult.copied, PageCopyResult.alreadyThere]); + expect(adds(container), hasLength(1)); }); - test('a page that cannot be read gets nothing, and the lineups stay', - () async { + test('a page that cannot be read gets nothing', () async { final (container, _, reader) = await open(); reader.fails = true; - expect(await send(container, move: true), LineUpPageResult.unreachable); + expect(await copyBolts(container), PageCopyResult.unreachable); expect(adds(container), isEmpty); - expect(linksHere(container), {'link-a', 'link-b', 'link-solo'}); }); - test('lineups this device cannot store to send stay here', () async { + test('a copy this device cannot store is not reported as copied', () async { final (container, queue, _) = await open(); queue.offCanvasStoreFails = true; - expect(await send(container, move: true), LineUpPageResult.notSaved); - expect(linksHere(container), {'link-a', 'link-b', 'link-solo'}); + expect(await copyBolts(container), PageCopyResult.notSaved); expect(container.read(strategySaveStateProvider).hasPendingCloudSync, isFalse); }); - test( - 'the lineups are sent as they were when the user asked, and stay ' - 'here if they changed meanwhile', () async { - final (container, _, reader) = await open(); - final read = reader.gate = Completer(); - - final moved = send(container, move: true); - // While page 3 is read, a teammate's edit renames Bolt A. - final lineUps = container.read(lineUpProvider.notifier); - lineUps.mergeRemote(LineUpGraph( - origins: container.read(lineUpProvider).origins, - landings: container.read(lineUpProvider).landings, - links: [ - for (final link in container.read(lineUpProvider).links) - link.id == 'link-a' ? link.copyWith(name: 'Renamed') : link, - ], - )); - read.complete(); - - // The copy is what the user asked to move; the renamed lineup stays. - expect(await moved, LineUpPageResult.copiedInstead); - expect( - _entries(cloudPayloadData(adds(container).single.payload), 'links') - .map((link) => link['name']), - ['Bolt A', 'Bolt B'], - ); - expect( - { - for (final link in container.read(lineUpProvider).links) - link.id: link.name, - }, - {'link-a': 'Renamed', 'link-b': 'Bolt B', 'link-solo': ''}, - ); - await _settle(); - }); - - test('lineups that share no spot are not sent, so none goes half-way', + test('lineups that share no spot are not copied, so none goes half-way', () async { final (container, _, _) = await open(); expect( - await container.read(strategyProvider.notifier).sendLineUpsToPage( + await container + .read(strategyProvider.notifier) + .copyLineUpsToAdjacentPage( linkIds: {'link-a', 'link-solo'}, - pageId: 'page-3', - move: true, + direction: PageTransitionDirection.forward, ), - LineUpPageResult.unavailable, + PageCopyResult.unavailable, ); expect(adds(container), isEmpty); - expect(linksHere(container), {'link-a', 'link-b', 'link-solo'}); - }); - - test('a move goes through when the lineups only came back in a new order', - () async { - final (container, _, reader) = await open(); - final read = reader.gate = Completer(); - - final moved = send(container, move: true); - // While page 3 is read, the page is drawn again from the same rows, - // in another order. - final now = container.read(lineUpProvider); - container.read(lineUpProvider.notifier).mergeRemote(LineUpGraph( - origins: now.origins.reversed.toList(), - landings: now.landings.reversed.toList(), - links: now.links.reversed.toList(), - )); - read.complete(); - - expect(await moved, LineUpPageResult.done); - expect(linksHere(container), {'link-solo'}); - await _settle(); - }); - - test('a move leaves the lineups here if a spot of theirs moved meanwhile', - () async { - final (container, _, reader) = await open(); - final read = reader.gate = Completer(); - - final moved = send(container, move: true); - // While page 3 is read, a teammate moves the landing both aim at. - final now = container.read(lineUpProvider); - container.read(lineUpProvider.notifier).mergeRemote(LineUpGraph( - origins: now.origins, - landings: [ - for (final landing in now.landings) - landing.id == 'land-ab' - ? landing.copyWith( - ability: landing.ability - .copyWith(position: const Offset(500, 500)), - ) - : landing, - ], - links: now.links, - )); - read.complete(); - - expect(await moved, LineUpPageResult.copiedInstead); - expect(linksHere(container), {'link-a', 'link-b', 'link-solo'}); - expect( - container.read(lineUpProvider).landingById('land-ab')!.ability.position, - const Offset(500, 500), - ); - await _settle(); }); - test('a move finishing after a lineup went from this page leaves the rest', - () async { + test('the lineups are copied as they were when the user asked', () async { final (container, _, reader) = await open(); final read = reader.gate = Completer(); - final moved = send(container, move: true); - // While page 3 is read, a teammate deletes Bolt B. + final copied = copyBolts(container); + // While page 3 is read, a teammate's edit renames Bolt A. final now = container.read(lineUpProvider); container.read(lineUpProvider.notifier).mergeRemote(LineUpGraph( origins: now.origins, landings: now.landings, links: [ for (final link in now.links) - if (link.id != 'link-b') link, + link.id == 'link-a' ? link.copyWith(name: 'Renamed') : link, ], )); read.complete(); - expect(await moved, LineUpPageResult.copiedInstead); - expect(adds(container), hasLength(1)); - expect(linksHere(container), {'link-a', 'link-solo'}); + expect(await copied, PageCopyResult.copied); + expect( + _entries(cloudPayloadData(adds(container).single.payload), 'links') + .map((link) => link['name']), + ['Bolt A', 'Bolt B'], + ); await _settle(); }); }); From 0f6a7f4a47a8be2ced87c29127f9d7cb4415aae2 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 10 Oct 2026 01:24:00 -0400 Subject: [PATCH 7/7] Don't offer a cloud lineup copy that couldn't be stored to send A lineup imported with a very long id was offered "Copy to next page" on a cloud strategy, and choosing it did nothing, because the copy's outbox key would pass Hive's 255-character limit. The menu now leaves it out. A test also pins that a throw spot whose other lineup is already on the next page doesn't offer it, while that lineup still can go from its own landing. Co-Authored-By: Claude Opus 5.5 --- lib/providers/strategy_provider.dart | 24 ++++++++++++++++++- test/strategy_page_semantics_test.dart | 20 ++++++++++++++++ test/strategy_page_session_provider_test.dart | 19 +++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 9f5eee4c..a8503148 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1110,7 +1110,8 @@ class StrategyProvider extends Notifier { if (snapshot == null || !_currentStrategyCanEditPages()) return const []; return [ for (final direction in PageTransitionDirection.values) - if (_adjacentCloudPageId(snapshot, direction) != null) direction, + if (_adjacentCloudPageId(snapshot, direction) case final pageId?) + if (_lineUpCopyFitsOutbox(pageId, linkIds)) direction, ]; } final pages = _orderedLocalPages(); @@ -1129,6 +1130,27 @@ class StrategyProvider extends Notifier { ]; } + /// Whether a cloud copy of the lineups [linkIds] onto [pageId] can be + /// stored to send. Hive refuses keys over 255 characters, and an op is + /// stored under one naming its account, strategy, page and group, whose id + /// is one of the copies' lineup ids. Only a lineup imported with an + /// unusually long id fails this, and it is then not offered. + bool _lineUpCopyFitsOutbox(String pageId, Set linkIds) { + final accountId = ref.read(strategyOpQueueProvider).accountId; + final strategyId = state.strategyId; + return accountId != null && + strategyId != null && + linkIds.every( + (id) => + DurableOutboxRecord.createStorageKey( + accountId: accountId, + strategyPublicId: strategyId, + entityKey: EntitySyncKey.lineup(pageId, newPageCopyId(id)), + ).length <= + 255, + ); + } + /// The local strategy's pages in order, or none. List _orderedLocalPages() { final strategyId = state.strategyId; diff --git a/test/strategy_page_semantics_test.dart b/test/strategy_page_semantics_test.dart index 47a7751c..bda9727c 100644 --- a/test/strategy_page_semantics_test.dart +++ b/test/strategy_page_semantics_test.dart @@ -503,6 +503,26 @@ void main() { ]); }); + test( + 'a throw spot whose other lineup is on the next page does not offer ' + 'it, and that lineup still can go from its own landing', () async { + final container = await open(); + final notifier = container.read(strategyProvider.notifier); + await notifier.copyLineUpsToAdjacentPage( + linkIds: {'recon'}, + direction: PageTransitionDirection.forward, + ); + + // Stand 1 throws Bolt A and Recon; Recon is already there. + expect(notifier.copyDirectionsForLineUps({'bolt-a', 'recon'}), [ + PageTransitionDirection.backward, + ]); + expect(notifier.copyDirectionsForLineUps({'bolt-a'}), [ + PageTransitionDirection.backward, + PageTransitionDirection.forward, + ]); + }); + test('a copy adds to the lineups already on the page', () async { final container = await open(nextPageLineUps: sovaLineUps(prefix: 'other-')); diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index d204bfef..3f7838c4 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -10878,6 +10878,25 @@ void main() { expect(adds(container), hasLength(1)); }); + test('a lineup whose copy could not be stored to send is not offered', + () async { + final (container, _, _) = await open(); + final long = 'x' * 200; + container.read(lineUpProvider.notifier).mergeRemote( + lineUpGraphFromCloudRows([ + CloudLineupRow.remote(_lineup('page-2', long)), + ]).graph, + ); + + expect( + container + .read(strategyProvider.notifier) + .copyDirectionsForLineUps({'link-$long'}), + isEmpty, + ); + await _settle(); + }); + test('a page that cannot be read gets nothing', () async { final (container, _, reader) = await open(); reader.fails = true;