import 'dart:async'; import 'dart:convert'; import 'dart:math' show max; import 'package:flutter/foundation.dart' show listEquals, setEquals; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/strategy/open_attempt.dart'; import 'package:hive_ce/hive.dart'; import 'package:icarus/collab/cloud_lineup_rows.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/map_doors.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/l10n/l10n.dart'; import 'package:icarus/const/sort_index_order.dart'; import 'package:icarus/page_transition/agent_path.dart'; import 'package:icarus/page_transition/navigation_geometry_map.dart'; import 'package:icarus/page_transition/transition_planner.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/editor_operation_provider.dart'; import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; import 'package:icarus/providers/collab/drawn_cloud_pages_provider.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/transition_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/providers/navigation_geometry_provider.dart'; import 'package:icarus/providers/view_cone_geometry_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/strategy/strategy_page_apply.dart'; import 'package:icarus/strategy/lineup_group_changes.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/strategy/strategy_page_source.dart'; import 'package:icarus/view_cone/vision_geometry.dart'; enum PageTransitionState { idle, animatingForward, animatingBackward, } enum PageSwitchDirection { next, previous } /// The page on screen, which a teammate deleted while it held work the server /// never got. [name] is its name when it was last loaded. typedef DeletedPage = ({String pageId, String name}); /// How Keep both ended (see StrategyPageSessionNotifier.keepBothForRejected). enum KeepBothOutcome { /// The cloud's versions stand, and the user's are beside them as copies. kept, /// The cloud's versions could not be loaded. Nothing changed. unchanged, /// The user's versions were added as copies, but something failed before /// the conflicts were resolved (the cloud's versions stopped loading, a /// copy could not be saved, or the waiting work changed), so they still /// wait. copiesOnly, } /// How an attempt to restore a deleted page ended. enum DeletedPageRestore { /// The page is back on the server, and the work on it is saving as usual. /// The deleted page on screen is back on screen too. restored, /// The server can no longer restore the page: its time in the trash is /// over. Work on it cannot be saved. gone, /// The page could not be restored, or not loaded once it was. Nothing on /// this device changed; trying again is safe. failed, } class StrategyPageSessionState { const StrategyPageSessionState({ required this.activePageId, required this.availablePageIds, required this.transitionState, required this.isApplyingPage, this.deletedPage, }); final String? activePageId; final List availablePageIds; final PageTransitionState transitionState; final bool isApplyingPage; /// Set until the user has read that their unsaved work on the deleted page /// on screen cannot be saved. The canvas stays on it until then. final DeletedPage? deletedPage; StrategyPageSessionState copyWith({ String? activePageId, bool clearActivePageId = false, List? availablePageIds, PageTransitionState? transitionState, bool? isApplyingPage, DeletedPage? deletedPage, bool clearDeletedPage = false, }) { return StrategyPageSessionState( activePageId: clearActivePageId ? null : (activePageId ?? this.activePageId), availablePageIds: availablePageIds ?? this.availablePageIds, transitionState: transitionState ?? this.transitionState, isApplyingPage: isApplyingPage ?? this.isApplyingPage, deletedPage: clearDeletedPage ? null : (deletedPage ?? this.deletedPage), ); } } /// How a page switch ended: on the page asked for, on the page it started /// from, or handed over to a newer switch (or another strategy) that /// started while it waited. enum _PageSwitch { switched, stayed, superseded } class _RemotePageHydrationKey { const _RemotePageHydrationKey({ required this.strategyPublicId, required this.pageId, required this.fingerprint, }); final String strategyPublicId; final String pageId; final String fingerprint; @override bool operator ==(Object other) { return other is _RemotePageHydrationKey && strategyPublicId == other.strategyPublicId && pageId == other.pageId && fingerprint == other.fingerprint; } @override int get hashCode => Object.hash( strategyPublicId, pageId, fingerprint, ); } final strategyPageSessionProvider = NotifierProvider( StrategyPageSessionNotifier.new, ); class StrategyPageSessionNotifier extends Notifier { _RemotePageHydrationKey? _lastHydratedRemotePageKey; RemoteEditorSnapshot? _lastAppliedRemoteSnapshot; bool _pendingRemoteReapply = false; bool _isResolvingConflicts = false; bool _remoteReapplyInFlight = false; /// A remote change is waiting for the user to finish what it touches. /// Retried when the editor's holds change, not before: they may last a /// while, and retrying sooner would only hold the change back again. bool _remoteChangeWaitsForEditor = false; bool _disposed = false; int _pageSessionGeneration = 0; /// Pages whose delete from this device the server applied, with the /// strategy revision the delete made. See [_deletedHere]. final Map _pagesDeletedHere = {}; /// The latest strategy revision at which each page was seen live. final Map _pagesSeenLiveAt = {}; /// Waiting work Keep both already put a copy of on the canvas, by /// [_waitingWork] entry: pressing it again finishes the job instead of /// copying the same version twice. /// The copy's lineup ids, by entry, so every press can check the copy /// is saved before anything is discarded. final Map<(EntitySyncKey, (String, String?)), List> _keptAsCopy = {}; @override StrategyPageSessionState build() { ref.onDispose(() => _disposed = true); ref.listen>( remoteEditorSnapshotProvider, (previous, next) { final strategyState = ref.read(strategyProvider); if (strategyState.source != StrategySource.cloud || !strategyState.isOpen) { return; } final snapshot = next.valueOrNull; if (snapshot == null || snapshot.pages.isEmpty) { return; } if (snapshot.header.publicId == strategyState.strategyId) { for (final page in snapshot.pages) { _pagesSeenLiveAt.update( page.publicId, (seen) => max(seen, snapshot.header.revision), ifAbsent: () => snapshot.header.revision, ); } _resendEditsForPagesBack(snapshot); // Edits the canvas could not hand over while it had no read of // its page to diff them against go now. if (snapshot.activePage?.page.publicId == state.activePageId && ref.read(strategySaveStateProvider.notifier).canvasHeld) { scheduleMicrotask(() { if (_disposed || !ref.read(strategySaveStateProvider.notifier).canvasHeld) { return; } unawaited(flushCurrentPage()); }); } } final pageIds = [...snapshot.pages] ..sortBySortIndex((item) => item.sortIndex); final orderedIds = pageIds.map((page) => page.publicId).toList(growable: false); if (!listEquals(orderedIds, state.availablePageIds)) { state = state.copyWith(availablePageIds: orderedIds); } final targetPageId = _resolveHydrationTargetPage(snapshot); if (targetPageId == null) { return; } final hydrationKey = _buildRemotePageHydrationKey(snapshot, targetPageId); if (hydrationKey == null) { return; } if (_lastHydratedRemotePageKey != hydrationKey) { _requestRemoteRehydrate(targetPageId); } }, ); ref.listen(strategySaveStateProvider, (_, __) { _resumePendingRemoteReapplyIfPossible(); }); void editorChanged() { if (_remoteChangeWaitsForEditor) { _remoteChangeWaitsForEditor = false; _pendingRemoteReapply = true; } // Draft completion publishes its saved value and history in the same // call stack. Let those writes finish before inspecting save state. scheduleMicrotask(_resumePendingRemoteReapplyIfPossible); } ref.listen(editorBusyProvider, (_, __) => editorChanged()); ref.listen?>( editorHeldEntitiesProvider, (_, __) => editorChanged()); ref.listen(strategyOpQueueProvider, (previous, next) { final previousAckBatch = previous?.lastAckBatch ?? const []; if (next.lastAckBatch.isEmpty || identical(previousAckBatch, next.lastAckBatch)) { return; } _notePagesDeletedHere(next); unawaited(_reconcileAcks(next.lastAcks, next.lastAckBatch)); }); return const StrategyPageSessionState( activePageId: null, availablePageIds: [], transitionState: PageTransitionState.idle, isApplyingPage: false, ); } /// Edits the server refused because their page was deleted go out again /// once the page is live: restored here, by a teammate, or while this /// strategy was closed. A refusal refreshes the snapshot, which then no /// longer lists the page, so this cannot loop. void _resendEditsForPagesBack(RemoteEditorSnapshot snapshot) { // A "Use cloud" in progress decides this work's fate. ("Keep mine" goes // through the same serialized retry, which only takes what is still in // attention, so the two cannot send it twice.) if (_isResolvingConflicts) return; final live = {for (final page in snapshot.pages) page.publicId}; final waiting = ref .read(strategyOpQueueProvider) .attentionByEntityKey .keys .any((key) => live.contains(key.pageId)); if (!waiting) return; unawaited( ref.read(strategyOpQueueProvider.notifier).retryRestoredPages(live), ); } String? get activePageId => state.activePageId; /// Opens [strategyId]'s pages on [preferredPageId] when it has one, else /// on the page already active, else (with [selectFirstPageIfNeeded]) the /// first. Future initializeForStrategy({ required String strategyId, required StrategySource source, required bool selectFirstPageIfNeeded, String? preferredPageId, OpenAttempt? attempt, }) async { // The open this is a step of, if any. It stops here too when the open // gave way (see [OpenAttempt]). final opening = attempt ?? OpenAttempt.unchallenged; _pageSessionGeneration++; // As in reset: a switch this supersedes leaves its transition here. ref.read(transitionProvider.notifier).complete(); state = state.copyWith(transitionState: PageTransitionState.idle); final pageSource = _resolvePageSource(strategyId, source); final pageIds = await opening.wait(pageSource.listPageIds()); // Drawn from this computer's copy, the page the copy could show is the // only one there is to open (see RemoteEditorSnapshotNotifier). final copiedPage = source == StrategySource.cloud ? ref.read(remoteEditorSnapshotProvider).valueOrNull?.activePage : null; final initialPageId = copiedPage != null && copiedPage.fromCopy && pageIds.contains(copiedPage.page.publicId) ? copiedPage.page.publicId : pageIds.contains(preferredPageId) ? preferredPageId : pageIds.contains(state.activePageId) ? state.activePageId : null; final selected = initialPageId ?? (selectFirstPageIfNeeded && pageIds.isNotEmpty ? pageIds.first : null); state = state.copyWith( availablePageIds: pageIds, activePageId: selected, clearActivePageId: selected == null, transitionState: PageTransitionState.idle, isApplyingPage: false, clearDeletedPage: true, ); ref.read(activePageLiveSyncProvider.notifier).setContext( strategyPublicId: strategyId, activePageId: selected, ); if (selected != null) { await opening.wait( _rehydrateActivePageFromSource(selected, attempt: opening), ); } // Snapshots that arrived while the strategy was opening went unheard: // catch up on pages that came back while it was closed. if (source == StrategySource.cloud) { final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot != null && snapshot.header.publicId == strategyId) { _resendEditsForPagesBack(snapshot); } } } Future setActivePage(String pageId) async { if (pageId == state.activePageId || _deletedPageHoldsWork()) { return; } // An animated switch still waiting gives way to this one and leaves its // transition (see _PageSwitch.superseded): this switch ends it. if (state.transitionState != PageTransitionState.idle) { ref.read(transitionProvider.notifier).complete(); state = state.copyWith(transitionState: PageTransitionState.idle); } final outcome = await _switchToPage(pageId, animated: false); if (outcome == _PageSwitch.stayed) _resumePendingRemoteReapplyIfPossible(); } Future setActivePageAnimated( String pageId, { required PageTransitionDirection direction, Duration duration = kPageTransitionDuration, }) async { if (pageId == state.activePageId || _deletedPageHoldsWork()) { return; } final previousPageId = state.activePageId; final transitionState = ref.read(transitionProvider); final transitionNotifier = ref.read(transitionProvider.notifier); if (transitionState.active || transitionState.phase == PageTransitionPhase.preparing) { transitionNotifier.complete(); } state = state.copyWith( transitionState: direction == PageTransitionDirection.forward ? PageTransitionState.animatingForward : PageTransitionState.animatingBackward, ); final startSettings = ref.read(strategySettingsProvider); final previous = _snapshotAllPlaced(); final previousDoors = _changedDoors(); final strategyState = ref.read(strategyProvider); final sourcePageId = state.activePageId; var fadeInDrawings = false; if (strategyState.source == StrategySource.local && strategyState.strategyId != null) { final strategy = Hive.box(HiveBoxNames.strategiesBox) .get(strategyState.strategyId); final targetPage = strategy?.pages.where((page) => page.id == pageId).firstOrNull; if (targetPage != null) { fadeInDrawings = TransitionPlanner.drawingsChanged( ref.read(drawingProvider).elements, targetPage.drawingData, ); } } transitionNotifier.prepare( previous.values.toList(), direction: direction, startAgentSize: startSettings.agentSize, startAbilitySize: startSettings.abilitySize, sourcePageId: sourcePageId, targetPageId: pageId, fadeInDrawings: fadeInDrawings, fromDoors: previousDoors, ); try { final outcome = await _switchToPage( pageId, animated: true, direction: direction, ); // A newer switch took over while this one waited: the transition is // that switch's now. if (outcome == _PageSwitch.superseded) return; if (outcome == _PageSwitch.stayed) { transitionNotifier.complete(); state = state.copyWith(transitionState: PageTransitionState.idle); _resumePendingRemoteReapplyIfPossible(); return; } } catch (error, stackTrace) { transitionNotifier.complete(); final strategyState = ref.read(strategyProvider); state = state.copyWith( activePageId: previousPageId, clearActivePageId: previousPageId == null, transitionState: PageTransitionState.idle, ); ref.read(activePageLiveSyncProvider.notifier).setContext( strategyPublicId: strategyState.strategyId, activePageId: previousPageId, ); if (strategyState.source == StrategySource.cloud) { try { await ref .read(remoteEditorSnapshotProvider.notifier) .setActivePage(previousPageId); final strategyId = strategyState.strategyId; if (strategyId != null && previousPageId != null) { final snapshot = _lastAppliedRemoteSnapshot; if (snapshot != null && snapshot.header.publicId == strategyId && snapshot.activePage?.page.publicId == previousPageId) { ref.read(activePageLiveSyncProvider.notifier).markPageHydrated( strategyPublicId: strategyId, pageId: previousPageId, snapshot: snapshot, ); } } } catch (_) { // Preserve the original switch failure; the live read can recover // independently without leaving the transition state stuck. } } _resumePendingRemoteReapplyIfPossible(); Error.throwWithStackTrace(error, stackTrace); } WidgetsBinding.instance.addPostFrameCallback((_) async { final preliminaryNext = _snapshotAllPlaced(); final preliminaryEntries = _diffToTransitions(previous, preliminaryNext); final doorsMove = !setEquals(previousDoors, _changedDoors()); if (preliminaryEntries.isEmpty && !fadeInDrawings && !doorsMove) { transitionNotifier.complete(); state = state.copyWith(transitionState: PageTransitionState.idle); _resumePendingRemoteReapplyIfPossible(); return; } final needsAgentRouting = preliminaryEntries.any( (entry) => entry.kind == TransitionKind.move && entry.visualWidget is PlacedAgentNode, ); // Agent routes load the small movement mesh independently of sightlines. // Unchanged agents, abilities and drawings need no route initialization. VisionGeometryMap? transitionGeometry; NavigationGeometryMap? transitionNavigation; final map = ref.read(mapProvider).currentMap; final usesNavigationMesh = ref.read(worldGeometryEnabledProvider(map)); if (needsAgentRouting) { try { if (usesNavigationMesh) { transitionNavigation = await ref.read(navigationGeometryProvider(map).future); } else { transitionGeometry = await ref.read(viewConeGeometryProvider(map).future); } } on Object { // The provider reports the failure. Agents then move straight. } } final currentTransition = ref.read(transitionProvider); if (currentTransition.phase != PageTransitionPhase.preparing || currentTransition.sourcePageId != sourcePageId || currentTransition.targetPageId != pageId) { return; } final next = _snapshotAllPlaced(); final entries = _diffToTransitions(previous, next); if (entries.isEmpty && !fadeInDrawings && !doorsMove) { transitionNotifier.complete(); state = state.copyWith(transitionState: PageTransitionState.idle); _resumePendingRemoteReapplyIfPossible(); return; } final endSettings = ref.read(strategySettingsProvider); final agentPaths = AgentTransitionPathPlanner.plan( entries: entries, geometry: transitionGeometry, navigation: transitionNavigation, startAgentSize: startSettings.agentSize, endAgentSize: endSettings.agentSize, coordinateSystem: CoordinateSystem.instance, ); transitionNotifier.start( entries, duration: duration, direction: direction, startAgentSize: startSettings.agentSize, endAgentSize: endSettings.agentSize, startAbilitySize: startSettings.abilitySize, endAbilitySize: endSettings.abilitySize, sourcePageId: sourcePageId, targetPageId: pageId, agentPaths: agentPaths, fadeInDrawings: fadeInDrawings, ); state = state.copyWith(transitionState: PageTransitionState.idle); _resumePendingRemoteReapplyIfPossible(); }); } /// The pages in order, as the pages bar lists them: a cloud strategy's /// with this device's page changes that have not landed on top. List get _pageOrder { if (ref.read(strategyProvider).source == StrategySource.cloud) { final drawn = ref.read(drawnCloudPagesProvider)?.pages; if (drawn != null) return [for (final page in drawn) page.publicId]; } return state.availablePageIds; } Future switchRelativePage(PageSwitchDirection direction) async { final pageIds = _pageOrder; if (pageIds.isEmpty) { return; } final active = state.activePageId ?? pageIds.first; final currentIndex = pageIds.indexOf(active); if (currentIndex < 0) { return; } final nextIndex = direction == PageSwitchDirection.next ? (currentIndex + 1) % pageIds.length : (currentIndex - 1 + pageIds.length) % pageIds.length; final nextPageId = pageIds[nextIndex]; await setActivePageAnimated( nextPageId, direction: direction == PageSwitchDirection.next ? PageTransitionDirection.forward : PageTransitionDirection.backward, ); } /// Hands the page on screen to its store (see /// [StrategyPageSource.flushCurrentPage]); with [flushImmediately], a /// cloud page's ops are sent too. False when the canvas could not be /// handed over. With no strategy open there is nothing to hand over. Future flushCurrentPage({bool flushImmediately = false}) async { final strategyState = ref.read(strategyProvider); if (!strategyState.isOpen || strategyState.strategyId == null) { return true; } final source = _resolvePageSource( strategyState.strategyId!, strategyState.source ?? StrategySource.local, ); if (strategyState.source == StrategySource.cloud) { ref.read(strategyProvider.notifier).consumeScheduledCloudPageSync(); } final handedOver = await source.flushCurrentPage(); if (flushImmediately && strategyState.source == StrategySource.cloud) { await ref.read(strategyOpQueueProvider.notifier).flushNow(); } return handedOver; } /// Keeps both versions of every conflicting lineup group: the cloud's /// stays the group, and the user's goes beside it as a copy under fresh /// ids (see forkLineUpGraph), so neither side's work is lost. A version /// that deletes the group, or only reorders it, holds nothing to copy. /// /// Only for conflicts that are all lineup groups on the page on screen /// (see lineupConflictsProvider); anything else is a [StateError]. /// /// Nothing is discarded unless everything still matches what was copied: /// the cloud's version loads, the page on screen is the same one, the /// waiting work is the same (an edit made meanwhile would not be in the /// copy), and the copy itself saved. When the cloud's version cannot be /// loaded, or the work changed before the copy was made, nothing changes. /// When something fails after, the copy stays and the conflicts still /// wait; pressing Keep both again resolves them without a second copy. Future keepBothForRejected() async { final strategyState = ref.read(strategyProvider); final strategyId = strategyState.strategyId; final queue = ref.read(strategyOpQueueProvider); final attention = queue.attentionByEntityKey; final activePageId = state.activePageId; if (strategyState.source != StrategySource.cloud || strategyId == null || attention.isEmpty || attention.keys.any((key) => key.kind != EntitySyncKeyKind.lineup || key.pageId != activePageId)) { throw StateError('Keep both is only for lineup groups on this page'); } // A copy is only kept if it can be saved; while this device cannot // save outbox records, nothing is copied and nothing discarded. if (queue.hasDurabilityFailure) return KeepBothOutcome.unchanged; final generation = _pageSessionGeneration; final waiting = _waitingWork(attention.keys); final copies = []; final copied = <(EntitySyncKey, (String, String?)), List>{}; for (final MapEntry(:key, value: refused) in attention.entries) { final newest = (queue.successorByEntityKey[key] ?? refused).pending.op; final payload = switch (newest) { LineupAddOp(:final payload) => payload, LineupPatchOp(:final payload?) => payload, _ => null, }; final entry = (key, waiting[key]!); if (payload == null) continue; // A copy made by an earlier press stands while any of its lineups is // on the canvas; once the user deleted it all, this press makes it // again. if (_keptAsCopy[entry] case final copyIds? when copyIds.any( (id) => ref.read(lineUpProvider).linkById(id) != null, )) { continue; } final copy = forkLineUpGraph(lineUpGraphFromCloudRows([ CloudLineupRow(publicId: key.entityId!, payload: payload), ]).graph); if (copy.links.isEmpty) continue; copies.add(copy); copied[entry] = [for (final link in copy.links) link.id]; } bool unchangedSince() => !_disposed && generation == _pageSessionGeneration && _sameWaitingWork(waiting); await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null || snapshot.header.publicId != strategyId) { return KeepBothOutcome.unchanged; } // Edits made while the cloud's version loaded are saved first; one to // a conflicting group is newer than the copy, so nothing goes ahead. final pageSource = _resolvePageSource(strategyId, StrategySource.cloud); await pageSource.flushCurrentPage(); if (!unchangedSince()) return KeepBothOutcome.unchanged; final lineUps = ref.read(lineUpProvider.notifier); for (final copy in copies) { lineUps.addRecovered(copy); } _keptAsCopy.addAll(copied); // Every copy of this work, made now or by an earlier press. final lineUpState = ref.read(lineUpProvider); final copyLineupIds = [ for (final MapEntry(:key, :value) in waiting.entries) if (_keptAsCopy[(key, value)] case final ids?) // One surviving lineup names the copy's group. ids.firstWhere( (id) => lineUpState.linkById(id) != null, orElse: () => ids.first, ), ]; await pageSource.flushCurrentPage(); // A copy that could not be saved leaves the outbox failing (and waits in // attention itself): nothing may be discarded on its behalf. final afterCopy = ref.read(strategyOpQueueProvider); final liveSync = ref.read(activePageLiveSyncProvider.notifier); final queuedKeys = { for (final pending in afterCopy.pending) EntitySyncKey.forStrategyOp(pending.op), }; // Each copy's group must be queued (or already on the server): a save // skipped without failing, say after edit access was lost, leaves the // copy on the canvas alone. final copiesQueued = copyLineupIds.every((lineupId) { final group = liveSync.lineupGroupOf(activePageId!, lineupId); if (group == null) return false; final key = EntitySyncKey.lineup(activePageId, group); return queuedKeys.contains(key) || liveSync.hydratedBase(key) != null; }); final saved = copiesQueued && !afterCopy.hasDurabilityFailure && afterCopy.attentionByEntityKey.keys.every(waiting.containsKey); if (!saved || !unchangedSince()) return KeepBothOutcome.copiesOnly; return await _useCloudVersionsFor(waiting) ? KeepBothOutcome.kept : KeepBothOutcome.copiesOnly; } /// The refused op, and the newer one queued behind it if any, of each of /// [keys]: what is waiting for the user's choice. Map _waitingWork( Iterable keys, ) { final queue = ref.read(strategyOpQueueProvider); return { for (final key in keys) if (queue.attentionByEntityKey[key] case final refused?) key: ( refused.pending.op.opId, queue.successorByEntityKey[key]?.pending.op.opId, ), }; } /// The canvas's content, one state object per kind of item on it. Every /// edit replaces its kind's object, so comparing them by identity tells /// whether anything changed in between. List _canvasStates() => [ ref.read(agentProvider), ref.read(abilityProvider), ref.read(drawingProvider), ref.read(textProvider), ref.read(placedImageProvider), ref.read(utilityProvider), ref.read(lineUpProvider), ]; bool _sameCanvas(List saved) { final now = _canvasStates(); for (var i = 0; i < saved.length; i++) { if (!identical(saved[i], now[i])) return false; } return true; } /// Whether exactly [waiting] still waits, nothing changed or gone. bool _sameWaitingWork(Map waiting) { final now = _waitingWork(waiting.keys); return now.length == waiting.length && waiting.entries.every((entry) => now[entry.key] == entry.value); } /// Adopts the cloud version for every current conflict in this strategy. /// /// The authoritative page is loaded before any local intent is discarded. /// A failed load therefore leaves the durable conflict available to retry. Future useCloudVersionsForRejected() => _useCloudVersionsFor(null); /// [useCloudVersionsForRejected], for exactly the conflicts in [waiting] /// (by default, every conflict once the page's edits are saved). If they /// no longer wait as they were, or the canvas changed after its last /// save, nothing is discarded or redrawn: the newer work stays. Future _useCloudVersionsFor( Map? waiting, ) async { final strategyState = ref.read(strategyProvider); final strategyId = strategyState.strategyId; if (strategyState.source != StrategySource.cloud || strategyId == null) { return false; } final generation = _pageSessionGeneration; late Map resolving; bool waitingChanged() => _disposed || generation != _pageSessionGeneration || !_sameWaitingWork(resolving); _isResolvingConflicts = true; try { // A page delete held for review is the user's own choice to make // (Delete anyway or Keep the page), not a conflict Use cloud settles. final held = ref.read(strategyOpQueueProvider.notifier).heldPageDeletes().keys; final conflictKeys = { ...ref.read(strategyOpQueueProvider).attentionByEntityKey.keys, }..removeAll(held); final currentPageSource = _resolvePageSource(strategyId, StrategySource.cloud); await currentPageSource.flushCurrentPage(); final strategyNotifier = ref.read(strategyProvider.notifier); strategyNotifier.consumeScheduledCloudPageSync(); strategyNotifier.consumeScheduledCloudStrategySync(); resolving = waiting ?? _waitingWork(conflictKeys); if (waitingChanged()) return false; final rejected = Map.from( ref.read(strategyOpQueueProvider).attentionByEntityKey, )..removeWhere((key, _) => !resolving.containsKey(key)); // Conflicts the save turned back into queued work (a record whose // removal failed before, say) were not resolved: that is no success. // Ones it settled (gone on both sides) are. if (rejected.isEmpty) { final pending = { for (final work in ref.read(strategyOpQueueProvider).pending) EntitySyncKey.forStrategyOp(work.op), }; return !conflictKeys.any(pending.contains); } await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null || snapshot.header.publicId != strategyId) { return false; } // Edits made while the cloud's version loaded are saved, and one to // the waiting work stops the resolution: the page drawn next would // replace that edit on screen. The canvas is noted before that save, // so an edit made while it is being written counts as a change too. final savedCanvas = _canvasStates(); final queueNotifier = ref.read(strategyOpQueueProvider.notifier); final failuresBefore = queueNotifier.persistenceFailureCount; await currentPageSource.flushCurrentPage(); // A save that failed just now may have dropped a newer edit the queue // never recorded; nothing is discarded or redrawn over it. (A failure // left from before, say a discard that did not finish, does not stop // the discard that can clear it.) if (waitingChanged() || queueNotifier.persistenceFailureCount != failuresBefore) { return false; } final targetPageId = _resolveHydrationTargetPage(snapshot); final localMetadata = _unsentLocalMetadata(); if (targetPageId != null) { final pageSource = CloudStrategyPageSource( ref, strategyId: strategyId, activePageId: () => state.activePageId, ); final pageData = await pageSource.loadAuthoritativePage( targetPageId, discardedEntities: rejected.keys.toSet(), ); // An edit since the last save is still only on the canvas, and // stops the redraw; checked again where the redraw happens, after // the page list it awaits. bool unchangedCanvas() => !waitingChanged() && _sameCanvas(savedCanvas); if (!unchangedCanvas()) return false; var redrawn = false; await _showCloudVersions( pageData, of: rejected.keys.toSet(), strategyId: strategyId, canApply: () => redrawn = unchangedCanvas(), loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot!, // Choosing the cloud's map and theme draws the server's, not the // refused change waiting over them (see drawnStrategyHeader). preservedMetadata: rejected.containsKey(const EntitySyncKey.strategy()) ? _cloudHeaderMetadata(snapshot.header) : localMetadata, ); if (!redrawn) return false; } // What the first redraw left: a redraw after a partial discard below // draws only over this, never over an edit made while discarding. final redrawnCanvas = _canvasStates(); final discarded = await ref .read(strategyOpQueueProvider.notifier) .discardRejected(rejected.keys.toSet(), onlyIf: resolving); // A failed durable delete keeps its overlay and conflict. Restore those // entities if only part of the requested adoption could be saved. if (discarded.length != rejected.length && targetPageId != null) { final pageSource = CloudStrategyPageSource( ref, strategyId: strategyId, activePageId: () => state.activePageId, ); // Redrawn only if nothing was edited since the first redraw. The // work that stays comes back over the cloud's version drawn first. final pageData = await pageSource.loadAuthoritativePage( targetPageId, discardedEntities: discarded, ); await _showCloudVersions( pageData, of: rejected.keys.toSet(), strategyId: strategyId, canApply: () => !_disposed && generation == _pageSessionGeneration && _sameCanvas(redrawnCanvas), loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot!, preservedMetadata: discarded.contains(const EntitySyncKey.strategy()) ? null : localMetadata, ); } if (discarded.isEmpty) return false; ref.read(activePageLiveSyncProvider.notifier).adoptRemoteForEntities( discarded, hydratedPageId: targetPageId, ); for (final entry in rejected.entries) { if (discarded.contains(entry.key)) { ref .read(strategyConflictProvider.notifier) .clear(entry.value.pending.op.opId); } } for (final key in discarded) { if (key.kind == EntitySyncKeyKind.element && key.entityId != null) { ref.read(textDraftProvider.notifier).clearDraft(key.entityId!); } } ref .read(strategyOpQueueProvider.notifier) .completeRemoteAdoption(discarded); return discarded.length == rejected.length; } finally { _isResolvingConflicts = false; // A teammate's change that waited for this, or for a hold the user // let go of meanwhile, applies now. if (!_disposed) _resumePendingRemoteReapplyIfPossible(); } } /// Draws [pageData], which has the version of the entities in [of] that /// the user's choice left: the cloud's, or their own where it could not be /// discarded. The page on screen takes it item by item, as it takes a /// teammate's change, so what the user is in the middle of stays as it is: /// a placement edit of other lineups keeps its drags. The entities in [of] /// take [pageData]'s version even when held, and a placement edit of a /// lineup among them ends. Any other page loads whole. Future _showCloudVersions( StrategyEditorPageData pageData, { required Set of, required String strategyId, required bool Function() canApply, required RemoteEditorSnapshot loadedRemoteSnapshot, ({MapValue map, StrategyThemeState theme})? preservedMetadata, }) async { final pageId = pageData.pageId; final liveSync = ref.read(activePageLiveSyncProvider.notifier); final onScreen = _lastHydratedRemotePageKey?.strategyPublicId == strategyId && _lastHydratedRemotePageKey?.pageId == pageId && ref.read(activePageLiveSyncProvider).hydratedPageId == pageId; if (!onScreen) { await _applyLoadedPageData( pageData, strategyId: strategyId, source: StrategySource.cloud, canApply: canApply, preserveTextDrafts: true, loadedRemoteSnapshot: loadedRemoteSnapshot, preservedMetadata: preservedMetadata, ); return; } // The cloud's version of a page (one this device added, say) can change // the list of pages. final availablePageIds = await _resolvePageSource(strategyId, StrategySource.cloud) .listPageIds(); if (!canApply()) return; // Whether the canvas item [id] (an element, or a lineup or one of its // spots) is in [of]. bool chosen(String id) => of.contains(EntitySyncKey.element(pageId, id)) || switch (liveSync.lineupGroupOf(pageId, id)) { final group? => of.contains(EntitySyncKey.lineup(pageId, group)), null => false, }; if (ref.read(lineUpProvider).edit?.linkIds.any(chosen) ?? false) { ref .read(interactionStateProvider.notifier) .update(InteractionState.navigation); } state = state.copyWith(availablePageIds: availablePageIds); _mergeRemotePage( pageData, strategyId: strategyId, snapshot: loadedRemoteSnapshot, // What the user is in the middle of stays, except what is in [of]. A // press off the canvas (on the conflict panel, say) holds nothing on // it. holding: { for (final id in ref.read(editorHeldCanvasItemsProvider)) if (!chosen(id)) id, }, preservedMetadata: preservedMetadata, ); } /// The strategy's map and theme on screen, while a change to them is still /// on its way: loading a page must keep them rather than take the older /// server copy (and so drop the change). ({MapValue map, StrategyThemeState theme})? _unsentLocalMetadata() { final hasPendingMetadata = ref.read(strategyOpQueueProvider).pending.any( (pending) { final op = pending.op; return op is StrategyPatchOp && op.payload.keys.any((key) => key == 'mapData' || key == 'themeProfileId' || key == 'clearThemeProfileId' || key == 'themeOverridePalette' || key == 'clearThemeOverridePalette'); }, ); return hasPendingMetadata ? ( map: ref.read(mapProvider).currentMap, theme: ref.read(strategyThemeProvider), ) : null; } bool get isApplyingPage => state.isApplyingPage; void setStateForTest(StrategyPageSessionState newState) { state = newState; } void reset() { _remoteChangeWaitsForEditor = false; _pageSessionGeneration++; // A switch still waiting gives way without ending its transition (see // _PageSwitch.superseded): ending the session ends it. ref.read(transitionProvider.notifier).complete(); state = const StrategyPageSessionState( activePageId: null, availablePageIds: [], transitionState: PageTransitionState.idle, isApplyingPage: false, ); _lastHydratedRemotePageKey = null; _lastAppliedRemoteSnapshot = null; _pendingRemoteReapply = false; _isResolvingConflicts = false; ref.read(activePageLiveSyncProvider.notifier).reset(); } /// Stays on the page on screen ([_PageSwitch.stayed]) if a teammate /// deleted it while its work was being flushed, or if the server can't /// send the cloud page asked for. Future<_PageSwitch> _switchToPage( String pageId, { required bool animated, PageTransitionDirection? direction, }) async { final generation = ++_pageSessionGeneration; final strategyState = ref.read(strategyProvider); final strategyId = strategyState.strategyId; final source = strategyState.source; if (strategyId == null || source == null) { return _PageSwitch.switched; } final pageSource = _resolvePageSource(strategyId, source); if (source == StrategySource.cloud) { ref.read(strategyProvider.notifier).consumeScheduledCloudPageSync(); } await pageSource.flushCurrentPage(); if (source == StrategySource.cloud) { await ref .read(strategyOpQueueProvider.notifier) .flushNow() .timeout(const Duration(milliseconds: 750), onTimeout: () {}); // The page may have been deleted while its work was flushed. if (_deletedPageHoldsWork()) return _PageSwitch.stayed; // Read the page before leaving this one. Leaving first dropped this // page's server copy, and with it every later edit's way into the // queue, whenever the read then failed (offline, say). final remote = ref.read(remoteEditorSnapshotProvider.notifier); bool superseded() => generation != _pageSessionGeneration || ref.read(strategyProvider).strategyId != strategyId; final RemotePageSnapshot target; try { target = await remote.fetchPage(pageId); } catch (error) { if (superseded()) return _PageSwitch.superseded; AppErrorReporter.reportInfo( 'Page $pageId could not be read; staying on this page: $error', source: 'strategy_page_session', ); _showPageUnreachable(pageId); return _PageSwitch.stayed; } if (superseded()) return _PageSwitch.superseded; // Or deleted while the page was read. if (_deletedPageHoldsWork()) return _PageSwitch.stayed; state = state.copyWith(activePageId: pageId); ref.read(activePageLiveSyncProvider.notifier).setContext( strategyPublicId: strategyId, activePageId: pageId, ); await remote.setActivePage(pageId, fetched: target); } final pageData = await pageSource.loadPage(pageId); await _applyLoadedPageData( pageData, strategyId: strategyId, source: source, loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot, ); if (animated && direction != null) { _updateHydrationBookkeeping(pageData.pageId); } return _PageSwitch.switched; } /// Says the page the user asked for could not be read, by the name the /// pages bar shows it under: with page changes waiting (a rename, or a /// move that renumbers automatic names), the server's last read names it /// differently. void _showPageUnreachable(String pageId) { final name = ref.read(drawnCloudPagesProvider)?.byId(pageId)?.name; try { Settings.showToast( message: name == null ? ref.read(l10nProvider).strategyMsgPageUnreachableUnnamed : ref.read(l10nProvider).strategyMsgPageUnreachable(name), backgroundColor: Settings.tacticalVioletTheme.destructive, ); } catch (error) { // No overlay to show it on (tests); the page staying put says enough. } } /// Reads the page an open starts on and puts it on the canvas. A step of /// the open [attempt] belongs to, so it stops like one (see [OpenAttempt]). Future _rehydrateActivePageFromSource( String pageId, { required OpenAttempt attempt, _RemotePageHydrationKey? hydrationKey, bool preserveTextDrafts = false, }) async { final opening = attempt; final strategyState = ref.read(strategyProvider); final strategyId = strategyState.strategyId; final source = strategyState.source; if (strategyId == null || source == null) { return; } ref.read(activePageLiveSyncProvider.notifier).setContext( strategyPublicId: strategyId, activePageId: pageId, ); if (source == StrategySource.cloud) { ref.read(activePageLiveSyncProvider.notifier).markPageUnhydrated( strategyPublicId: strategyId, pageId: pageId, ); } final pageSource = _resolvePageSource(strategyId, source); final pageData = await opening.wait(pageSource.loadPage(pageId)); await opening.wait( _applyLoadedPageData( pageData, strategyId: strategyId, source: source, hydrationKey: hydrationKey, preserveTextDrafts: preserveTextDrafts, loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot, attempt: opening, ), ); } Future _applyLoadedPageData( StrategyEditorPageData pageData, { required String strategyId, required StrategySource source, _RemotePageHydrationKey? hydrationKey, bool preserveTextDrafts = false, RemoteEditorSnapshot? loadedRemoteSnapshot, ({MapValue map, StrategyThemeState theme})? preservedMetadata, bool Function()? canApply, OpenAttempt? attempt, }) async { // The open this page is read for, if it is one: a page read for an open // that gave way must not replace the canvas of what took its place. final opening = attempt ?? OpenAttempt.unchallenged; final availablePageIds = await opening.wait( _resolvePageSource(strategyId, source).listPageIds(), ); if (canApply != null && !canApply()) return; final preserveHistory = source == StrategySource.cloud && _lastHydratedRemotePageKey?.strategyPublicId == strategyId && _lastHydratedRemotePageKey?.pageId == pageData.pageId; final themeProfileId = preservedMetadata != null ? preservedMetadata.theme.profileId : _resolveThemeProfileId(source, strategyId); final themeOverridePalette = preservedMetadata != null ? preservedMetadata.theme.overridePalette : _resolveThemeOverridePalette(source, strategyId); state = state.copyWith( isApplyingPage: true, activePageId: pageData.pageId, availablePageIds: availablePageIds, ); final retainedTextDrafts = preserveTextDrafts ? Map.from(ref.read(textDraftProvider)) : const {}; try { await opening.wait( applyStrategyEditorPageData( ref, pageData, themeProfileId: themeProfileId, themeOverridePalette: themeOverridePalette, preserveHistory: preserveHistory, mapOverride: preservedMetadata?.map, ), ); for (final entry in retainedTextDrafts.entries) { ref.read(textDraftProvider.notifier).setDraft(entry.key, entry.value); } if (source == StrategySource.cloud) { if (loadedRemoteSnapshot == null) { throw StateError( 'Cloud page loaded without its source snapshot.', ); } ref.read(activePageLiveSyncProvider.notifier).markPageHydrated( strategyPublicId: strategyId, pageId: pageData.pageId, snapshot: loadedRemoteSnapshot, ); _lastAppliedRemoteSnapshot = loadedRemoteSnapshot; } _updateHydrationBookkeeping( pageData.pageId, hydrationKey: source == StrategySource.cloud ? _buildRemotePageHydrationKey( loadedRemoteSnapshot!, pageData.pageId, ) : hydrationKey, ); } finally { // After an open gave way the session belongs to what replaced it. if (opening.isCurrent) { state = state.copyWith( activePageId: pageData.pageId, isApplyingPage: false, ); _resumePendingRemoteReapplyIfPossible(); } } } StrategyPageSource _resolvePageSource( String strategyId, StrategySource source, ) { switch (source) { case StrategySource.local: return LocalStrategyPageSource( ref, strategyId: strategyId, activePageId: () => state.activePageId, ); case StrategySource.cloud: return CloudStrategyPageSource( ref, strategyId: strategyId, activePageId: () => state.activePageId, ); } } /// The theme profile the strategy names. A cloud strategy may name none /// (a custom palette alone, say): the canvas keeps none too, so it /// compares equal to the header it was drawn from, and the palette shown /// falls back to the default (see effectiveMapThemePaletteProvider). String? _resolveThemeProfileId(StrategySource source, String strategyId) { if (source == StrategySource.cloud) { final header = ref.read(remoteEditorSnapshotProvider).valueOrNull?.header; return header == null ? null : drawnStrategyHeader(ref, header).themeProfileId; } final strategy = Hive.box(HiveBoxNames.strategiesBox).get( strategyId, ); return strategy?.themeProfileId ?? MapThemeProfilesProvider.immutableDefaultProfileId; } MapThemePalette? _resolveThemeOverridePalette( StrategySource source, String strategyId, ) { if (source == StrategySource.cloud) { final header = ref.read(remoteEditorSnapshotProvider).valueOrNull?.header; return _cloudThemeOverridePalette( header == null ? null : drawnStrategyHeader(ref, header), ); } return Hive.box(HiveBoxNames.strategiesBox) .get(strategyId) ?.themeOverridePalette; } static MapThemePalette? _cloudThemeOverridePalette( RemoteStrategyHeader? header, ) { final payload = header?.themeOverridePalette; if (payload == null || payload.isEmpty) { return null; } try { return MapThemePalette.fromJson(payload); } catch (_) { return null; } } /// The map and theme [header] (the server's) sets, to draw in place of /// what the canvas has when the user chooses the cloud's version of them. static ({MapValue map, StrategyThemeState theme}) _cloudHeaderMetadata( RemoteStrategyHeader header, ) => ( map: Maps.mapNames.entries .firstWhere( (entry) => entry.value == header.mapData, orElse: () => const MapEntry(MapValue.ascent, 'ascent'), ) .key, theme: StrategyThemeState( profileId: header.themeProfileId, overridePalette: _cloudThemeOverridePalette(header), ), ); bool _canSafelyReapplyRemotePage() { final saveState = ref.read(strategySaveStateProvider); return !_isResolvingConflicts && state.deletedPage == null && !state.isApplyingPage && state.transitionState == PageTransitionState.idle && ref.read(editorHeldEntitiesProvider) != null && !saveState.isDirty && !saveState.isSaving && !saveState.hasPendingCloudSync; } void _requestRemoteRehydrate(String pageId) { if (_canSafelyReapplyRemotePage()) { unawaited(_reapplyRemotePage(pageId)); } else { _pendingRemoteReapply = true; _checkActivePageOnServer(); } } /// A replace of the page on screen is waiting. If it waits on work for a /// page a teammate deleted, the user is told now, unless they are /// mid-gesture: the gesture ending asks again. void _checkActivePageOnServer() { if (_isResolvingConflicts || state.isApplyingPage || state.transitionState != PageTransitionState.idle || ref.read(editorBusyProvider)) { return; } _deletedPageHoldsWork(); } /// Whether the canvas is on a page a teammate deleted, holding work the /// server never got. That work can never be sent, so waiting for it would /// wait forever and replacing the page would drop it unseen: the user is /// told first ([StrategyPageSessionState.deletedPage]). Checked before the /// canvas leaves the page, and while a replace of it waits. With nothing /// unsent, the unsaved mark its edits left is stale and is dropped. bool _deletedPageHoldsWork() { if (state.deletedPage != null) return true; final pageId = state.activePageId; final strategy = ref.read(strategyProvider); final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (pageId == null || !strategy.isOpen || strategy.source != StrategySource.cloud || snapshot == null || snapshot.header.publicId != strategy.strategyId || snapshot.pages.any((page) => page.publicId == pageId) || _lastHydratedRemotePageKey?.pageId != pageId) { return false; } // The user deleted it here: their delete landed, or is still on its way. // Work on it the server paused or refused stays in the sync status. final queue = ref.read(strategyOpQueueProvider); _notePagesDeletedHere(queue); if (_deletedHere(pageId)) return false; if (ref.read(strategyOpQueueProvider.notifier).isDeletedHere(pageId)) { return false; } if (!ref.read(activePageLiveSyncProvider.notifier).hasUnsentWork(pageId)) { ref.read(strategySaveStateProvider.notifier).clearStaleCloudMark(); return false; } final lastSeen = _lastAppliedRemoteSnapshot?.pages .where((page) => page.publicId == pageId) .firstOrNull; state = state.copyWith( deletedPage: ( pageId: pageId, name: lastSeen?.name ?? ref.read(l10nProvider).strategyMsgDeletedPageFallbackName, ), ); return true; } /// Records the pages whose delete the server just accepted from [queue]. /// Called from the check as well as the queue listener: a listener /// registered earlier (save state) can run the check first. void _notePagesDeletedHere(StrategyOpQueueState queue) { for (final acked in queue.lastAckBatch) { // Only a delete the server applied took the page away. A no-op says // it was already gone, not by whom: a teammate may have deleted it // since, and their delete must still show the notice. if ((acked.op, acked.ack) case ( PageDeleteOp(:final pagePublicId), AppliedOpAck(:final revision), )) { _pagesDeletedHere[pagePublicId] = revision; } } } /// Whether [pageId]'s disappearing is this device's delete: one the server /// applied that is newer than the page was last seen live. Seen live at /// the delete's revision or later, the page was restored after it (the /// delete's own revision never lists it), and a later delete is someone's /// new one, whichever order the ack and the reads arrive in. bool _deletedHere(String pageId) { final deletedAt = _pagesDeletedHere[pageId]; return deletedAt != null && (_pagesSeenLiveAt[pageId] ?? -1) < deletedAt; } /// Lets the deleted page on screen go, with the unsaved work on it, and /// puts a page the server has on screen. Returns false, staying on the /// deleted page, while some of that work is still on its way and cannot be /// taken back, or if no other page could be loaded. Future leaveDeletedPage() async { final deleted = state.deletedPage; final strategyId = ref.read(strategyProvider).strategyId; if (deleted == null || strategyId == null) return false; if (!await _withdrawPageWork(deleted.pageId) || state.deletedPage != deleted) { return false; } // Images only that work placed have nothing left to show them. unawaited(ref .read(cloudMediaUploadQueueProvider.notifier) .recheckAfterDiscardedWork(strategyId)); // A read that failed earlier would otherwise fail every retry. final remote = ref.read(remoteEditorSnapshotProvider.notifier); await remote.refresh(); final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; final target = snapshot == null ? null : _resolveHydrationTargetPage(snapshot); if (target == null || state.deletedPage != deleted) return false; // Loaded here, not through the reapply that waits for pending cloud // work: work queued for other pages is not on this canvas, and the // deleted page must not stay editable while it waits. If the page is // back (restored meanwhile), this is its server copy, without the // discarded work. try { await remote.setActivePage(target); final source = _resolvePageSource(strategyId, StrategySource.cloud); final pageData = await source.loadPage(target); if (state.deletedPage != deleted) return false; await _applyLoadedPageData( pageData, strategyId: strategyId, source: StrategySource.cloud, loadedRemoteSnapshot: source.loadedRemoteSnapshot, preservedMetadata: _unsentLocalMetadata(), ); } catch (error, stackTrace) { AppErrorReporter.reportError( 'Could not load a page to replace the deleted one.', error: error, stackTrace: stackTrace, source: 'strategy_page_session:leave_deleted_page', promptUser: false, ); return false; } ref.read(strategySaveStateProvider.notifier).clearStaleCloudMark(); state = state.copyWith(clearDeletedPage: true); return true; } /// Brings the deleted page on screen back from the server's trash, with /// everything it had there, so the unsaved work on it saves as usual: the /// changes the server refused while it was in the trash are sent again, /// and the canvas's other edits are queued against the restored copy. The /// notice stays until the page is back on screen, read live. Future restoreDeletedPage() async { final deleted = state.deletedPage; final strategyId = ref.read(strategyProvider).strategyId; if (deleted == null || strategyId == null) { return DeletedPageRestore.failed; } final outcome = await _restoreOnServer(strategyId, deleted.pageId); if (outcome != DeletedPageRestore.restored) return outcome; // A change still on its way when the page came back may yet be refused; // its answer comes before the refused changes are sent again. final settled = await _queueSettles((queue) => !queue .inFlightByEntityKey.keys .any((key) => key.pageId == deleted.pageId)); // Its answer never came: a refusal still on its way would miss the // retry below. Restoring again is safe once it has. if (!settled) return DeletedPageRestore.failed; await ref .read(remoteEditorSnapshotProvider.notifier) .showRestoredPage(deleted.pageId); final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (state.deletedPage != deleted || snapshot == null || snapshot.activePage?.page.publicId != deleted.pageId || !snapshot.pages.any((page) => page.publicId == deleted.pageId)) { return DeletedPageRestore.failed; } // Loading the page that was to replace it moved live sync off it. ref .read(activePageLiveSyncProvider.notifier) .resumePage(strategyPublicId: strategyId, pageId: deleted.pageId); state = state.copyWith(clearDeletedPage: true); await ref .read(strategyOpQueueProvider.notifier) .retryRestoredPage(deleted.pageId); await flushCurrentPage(flushImmediately: true); // A send whose answer was lost replays under its own op id, and may be // answered with the refusal the server recorded while the page was in // the trash: once the page's sends are answered, those go again too. // Sends still unanswered after the wait stay queued, saving as usual; // a late refusal among them shows in the sync status, where Keep mine // sends it again. if (await _queueSettles((queue) => ![ queue.queuedByEntityKey, queue.inFlightByEntityKey, ].any((sends) => sends.keys.any((key) => key.pageId == deleted.pageId)))) { await ref .read(strategyOpQueueProvider.notifier) .retryRestoredPage(deleted.pageId); } // A later read may have shown the page gone again. return state.deletedPage == null && state.activePageId == deleted.pageId ? DeletedPageRestore.restored : DeletedPageRestore.failed; } /// Brings [pageId] back from the server's trash, from Recently deleted: at /// the place it was deleted from, with everything on it. Changes to it the /// server refused while it was in the trash are sent again, once any /// still on their way have been answered, and again once the page's /// queued sends have gone: a send whose answer was lost replays under its /// own op id, and may bring back the refusal. Refusals not answered in /// time stay in the sync status, where Keep mine sends them. Future restorePageFromTrash(String pageId) async { final strategyId = ref.read(strategyProvider).strategyId; if (strategyId == null) return DeletedPageRestore.failed; final outcome = await _restoreOnServer(strategyId, pageId); if (outcome != DeletedPageRestore.restored) return outcome; final queue = ref.read(strategyOpQueueProvider.notifier); bool onPage(EntitySyncKey key) => key.pageId == pageId; if (await _queueSettles( (state) => !state.inFlightByEntityKey.keys.any(onPage))) { await queue.retryRestoredPage(pageId); } if (await _queueSettles((state) => ![ state.queuedByEntityKey, state.inFlightByEntityKey, ].any((sends) => sends.keys.any(onPage)))) { await queue.retryRestoredPage(pageId); } return DeletedPageRestore.restored; } /// Asks the server to take [pageId] out of its trash. Future _restoreOnServer( String strategyId, String pageId, ) async { try { await ref.read(convexStrategyRepositoryProvider).restorePage( strategyPublicId: strategyId, pagePublicId: pageId, ); return DeletedPageRestore.restored; } catch (error, stackTrace) { if (isTypedConvexNotFoundError(error)) return DeletedPageRestore.gone; AppErrorReporter.reportError( 'Could not restore a deleted page.', error: error, stackTrace: stackTrace, source: 'strategy_page_session:restore_page', promptUser: false, ); return DeletedPageRestore.failed; } } Future _reapplyRemotePage(String pageId) async { if (_remoteReapplyInFlight || !_canSafelyReapplyRemotePage()) { _pendingRemoteReapply = true; return; } final strategy = ref.read(strategyProvider); final strategyId = strategy.strategyId; if (!strategy.isOpen || strategy.source != StrategySource.cloud || strategyId == null) return; final generation = _pageSessionGeneration; final startingPageId = state.activePageId; // The clean canvas is about to consume the server copy. A previously // scheduled diff must not re-author that already-saved work while loading. ref.read(strategyProvider.notifier).consumeScheduledCloudPageSync(); _remoteReapplyInFlight = true; _pendingRemoteReapply = false; try { final source = _resolvePageSource(strategyId, StrategySource.cloud); final pageData = await source.loadPage(pageId); if (_disposed || generation != _pageSessionGeneration) return; bool canApply() { if (_disposed || generation != _pageSessionGeneration) return false; final current = ref.read(strategyProvider); if (!current.isOpen || current.strategyId != strategyId || current.source != StrategySource.cloud || state.activePageId != startingPageId) return false; // Loading yields. A new gesture, local commit, or newer remote // snapshot may have arrived since the request started. final loadedKey = _buildRemotePageHydrationKey( source.loadedRemoteSnapshot!, pageId, ); if (!_canSafelyReapplyRemotePage() || loadedKey != _currentRemotePageHydrationKey(pageId)) { _pendingRemoteReapply = true; return false; } return true; } final loadedSnapshot = source.loadedRemoteSnapshot!; final alreadyOnScreen = _lastHydratedRemotePageKey?.strategyPublicId == strategyId && _lastHydratedRemotePageKey?.pageId == pageId && ref.read(activePageLiveSyncProvider).hydratedPageId == pageId; if (alreadyOnScreen) { if (canApply()) { _mergeRemotePage( pageData, strategyId: strategyId, snapshot: loadedSnapshot, ); } return; } await _applyLoadedPageData( pageData, strategyId: strategyId, source: StrategySource.cloud, loadedRemoteSnapshot: loadedSnapshot, // Replacing the page (say a teammate deleted the one on screen) // clears everything, so it waits until nothing is mid-way. canApply: () { if (ref.read(editorBusyProvider)) { _remoteChangeWaitsForEditor = true; return false; } if (_deletedPageHoldsWork()) return false; return canApply(); }, ); } finally { _remoteReapplyInFlight = false; if (!_disposed) _resumePendingRemoteReapplyIfPossible(); } } /// Brings the page on screen up to [snapshot] item by item, leaving what /// the user is in the middle of changing. A held item's change applies once /// the hold ends. [holding] defaults to what the editor holds now; /// [preservedMetadata] is as for [_applyLoadedPageData]. void _mergeRemotePage( StrategyEditorPageData pageData, { required String strategyId, required RemoteEditorSnapshot snapshot, Set? holding, ({MapValue map, StrategyThemeState theme})? preservedMetadata, }) { final pageId = pageData.pageId; final liveSync = ref.read(activePageLiveSyncProvider.notifier); // Marks these writes as the server's, not edits to send back. state = state.copyWith(isApplyingPage: true); final Set heldBack; try { heldBack = mergeRemoteStrategyEditorPageData( ref, pageData, changed: liveSync.remoteChangesSinceHydration(snapshot, pageId), holding: holding ?? ref.read(editorHeldEntitiesProvider)!, themeProfileId: preservedMetadata != null ? preservedMetadata.theme.profileId : _resolveThemeProfileId(StrategySource.cloud, strategyId), themeOverridePalette: preservedMetadata != null ? preservedMetadata.theme.overridePalette : _resolveThemeOverridePalette(StrategySource.cloud, strategyId), mapOverride: preservedMetadata?.map, ); } finally { state = state.copyWith(isApplyingPage: false); } liveSync.markPageHydrated( strategyPublicId: strategyId, pageId: pageId, snapshot: snapshot, keepBaseFor: heldBack, ); _lastAppliedRemoteSnapshot = snapshot; _updateHydrationBookkeeping( pageId, hydrationKey: _buildRemotePageHydrationKey(snapshot, pageId), ); if (heldBack.isNotEmpty) _remoteChangeWaitsForEditor = true; } String? _resolveHydrationTargetPage(RemoteEditorSnapshot snapshot) { if (snapshot.pages.isEmpty) { return null; } final activePageId = state.activePageId; if (activePageId != null && snapshot.pages.any((page) => page.publicId == activePageId)) { return activePageId; } // The page the snapshot opened on: the first one the pages bar lists // (see RemoteEditorSnapshotNotifier), which a page deleted here is not. final opened = snapshot.activePage?.page.publicId; if (opened != null && snapshot.pages.any((page) => page.publicId == opened)) { return opened; } final pages = [...snapshot.pages] ..sortBySortIndex((item) => item.sortIndex); return pages.first.publicId; } void _updateHydrationBookkeeping( String pageId, { _RemotePageHydrationKey? hydrationKey, }) { final key = hydrationKey ?? _currentRemotePageHydrationKey(pageId); if (key == null) { return; } _lastHydratedRemotePageKey = key; } _RemotePageHydrationKey? _currentRemotePageHydrationKey(String pageId) { final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; if (snapshot == null) { return null; } return _buildRemotePageHydrationKey(snapshot, pageId); } _RemotePageHydrationKey? _buildRemotePageHydrationKey( RemoteEditorSnapshot snapshot, String pageId, ) { RemotePage? page; for (final candidate in snapshot.pages) { if (candidate.publicId == pageId) { page = candidate; break; } } if (page == null) { return null; } final pageSnapshot = snapshot.activePage; if (pageSnapshot == null || pageSnapshot.page.publicId != pageId) { return null; } final elements = [ ...snapshot.elementsByPage[pageId] ?? const [] ]..sort(_compareRemoteElements); final lineups = [ ...snapshot.lineupsByPage[pageId] ?? const [] ]..sort(_compareRemoteLineups); final assets = snapshot.assetsById.values.toList() ..sort((a, b) => a.publicId.compareTo(b.publicId)); final fingerprint = jsonEncode({ 'page': { 'publicId': page.publicId, 'name': page.name, 'sortIndex': page.sortIndex, 'isAttack': page.isAttack, 'revision': page.revision, 'contentRevision': pageSnapshot.content.revision, 'settings': pageSnapshot.content.settings, }, 'elements': [ for (final element in elements) { 'publicId': element.publicId, 'elementType': element.elementType, 'payload': element.payload, 'sortIndex': element.sortIndex, 'revision': element.revision, 'deleted': element.deleted, }, ], 'lineups': [ for (final lineup in lineups) { 'publicId': lineup.publicId, 'payload': lineup.payload, 'sortIndex': lineup.sortIndex, 'revision': lineup.revision, 'deleted': lineup.deleted, }, ], 'assets': [ for (final asset in assets) { 'publicId': asset.publicId, 'fileExtension': asset.fileExtension, 'mimeType': asset.mimeType, 'width': asset.width, 'height': asset.height, 'url': asset.url, 'legacyStoragePath': asset.legacyStoragePath, }, ], }); return _RemotePageHydrationKey( strategyPublicId: snapshot.header.publicId, pageId: pageId, fingerprint: fingerprint, ); } int _compareRemoteElements(RemoteElement a, RemoteElement b) { final sortCompare = a.sortIndex.compareTo(b.sortIndex); if (sortCompare != 0) { return sortCompare; } return a.publicId.compareTo(b.publicId); } int _compareRemoteLineups(RemoteLineup a, RemoteLineup b) { final sortCompare = a.sortIndex.compareTo(b.sortIndex); if (sortCompare != 0) { return sortCompare; } return a.publicId.compareTo(b.publicId); } Future _reconcileAcks( List acks, List ackBatch, ) async { final strategyState = ref.read(strategyProvider); if (strategyState.source != StrategySource.cloud || acks.isEmpty) { return; } ref.read(activePageLiveSyncProvider.notifier).recordAckBatch(ackBatch); var hasReject = false; for (final ack in acks) { if (ack.isAck) { continue; } hasReject = true; Map? serverPayload; if (ack.latestPayload != null && ack.latestPayload!.isNotEmpty) { serverPayload = cloudPayloadData(ack.latestPayload); } ref.read(strategyConflictProvider.notifier).push( ConflictResolution( type: ConflictResolutionType.rebase, opId: ack.opId, message: ack.reason, serverPayload: serverPayload, serverRevision: ack.latestRevision, ), ); } await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); if (_remoteReapplyInFlight || !_canSafelyReapplyRemotePage()) { _pendingRemoteReapply = true; return; } final activePageId = state.activePageId; final strategyId = strategyState.strategyId; if (activePageId != null && strategyId != null) { ref.read(strategyProvider.notifier).consumeScheduledCloudPageSync(); final liveSync = ref.read(activePageLiveSyncProvider.notifier); final madeLive = liveSync.liveStampForEdits(activePageId); final desiredOpsByEntityKey = liveSync.syncLocalPage( strategyPublicId: strategyId, pageId: activePageId, ); if (desiredOpsByEntityKey != null) { await ref.read(strategyOpQueueProvider.notifier).syncDesiredOpsForPage( pageId: activePageId, desiredOpsByEntityKey: desiredOpsByEntityKey, flushImmediately: false, madeLive: madeLive, ); } if (_canSafelyReapplyRemotePage()) { await _reapplyRemotePage(activePageId); } else { _pendingRemoteReapply = true; } } else if (hasReject) { _pendingRemoteReapply = true; } } /// How long leaving a deleted page waits on work already sent for it. @visibleForTesting Duration pageWorkSettleTimeout = const Duration(seconds: 10); /// Takes every op for [pageId] out of the queue, with the local intent /// (overlays) they carried. An op already sent cannot be taken back, so /// this waits for it to be answered, then takes out what it left. Returns /// whether the queue holds nothing for the page. Future _withdrawPageWork(String pageId) async { final queue = ref.read(strategyOpQueueProvider.notifier); if (!await queue.discardDeletedPage(pageId)) { await _queueSettles((state) => !state.inFlightByEntityKey.keys.any((key) => key.pageId == pageId)); await queue.discardDeletedPage(pageId); } ref.read(activePageLiveSyncProvider.notifier).dropSatisfiedOverlays(pageId); final state = ref.read(strategyOpQueueProvider); return ![ state.queuedByEntityKey, state.inFlightByEntityKey, state.successorByEntityKey, state.pausedByEntityKey, state.attentionByEntityKey, ].any((intents) => intents.keys.any((key) => key.pageId == pageId)); } /// Waits, at most [pageWorkSettleTimeout], for the op queue to satisfy /// [settled]. Returns whether it did. Future _queueSettles( bool Function(StrategyOpQueueState queue) settled, ) async { if (settled(ref.read(strategyOpQueueProvider))) return true; final done = Completer(); final subscription = ref.listen( strategyOpQueueProvider, (_, next) { if (settled(next) && !done.isCompleted) done.complete(); }, ); try { await done.future.timeout(pageWorkSettleTimeout, onTimeout: () {}); } finally { subscription.close(); } return done.isCompleted; } void _resumePendingRemoteReapplyIfPossible() { if (_disposed || _remoteReapplyInFlight || !_pendingRemoteReapply) { return; } if (!_canSafelyReapplyRemotePage()) { _checkActivePageOnServer(); return; } _pendingRemoteReapply = false; final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; final pageId = snapshot == null ? null : _resolveHydrationTargetPage(snapshot); if (pageId != null) { unawaited( _reapplyRemotePage(pageId), ); } } Map _snapshotAllPlaced() { final map = {}; for (final agent in ref.read(agentProvider)) { map[agent.id] = agent; } for (final ability in ref.read(abilityProvider)) { map[ability.id] = ability; } for (final text in ref.read(textProvider)) { map[text.id] = text; } for (final image in ref.read(placedImageProvider).images) { map[image.id] = image; } final currentMap = ref.read(mapProvider).currentMap; for (final utility in ref.read(utilityProvider)) { // A door turns in its own layer. if (mapDoorOfRow(utility, currentMap) == null) map[utility.id] = utility; } return map; } /// The map doors changed on the page on screen. Set _changedDoors() => changedMapDoors( ref.read(utilityProvider), ref.read(mapProvider).currentMap, ); List _diffToTransitions( Map previous, Map next, ) => TransitionPlanner.diff(previous, next); }