diff --git a/lib/providers/collab/active_page_live_sync_models.dart b/lib/providers/collab/active_page_live_sync_models.dart new file mode 100644 index 00000000..3fef43e9 --- /dev/null +++ b/lib/providers/collab/active_page_live_sync_models.dart @@ -0,0 +1,181 @@ +import 'package:icarus/collab/collab_models.dart'; + +typedef EntitySyncKey = String; + +enum ActivePageOverlayEntityType { pageSettings, element, lineup } + +EntitySyncKey pageSettingsEntityKey(String pageId) => 'page:$pageId:settings'; + +EntitySyncKey elementEntityKey(String pageId, String elementId) => + 'element:$pageId:$elementId'; + +EntitySyncKey lineupEntityKey(String pageId, String lineupId) => + 'lineup:$pageId:$lineupId'; + +String? pageIdForEntityKey(EntitySyncKey entityKey) { + final parts = entityKey.split(':'); + if (parts.length < 2) { + return null; + } + return parts[1]; +} + +String? entityIdForEntityKey(EntitySyncKey entityKey) { + final parts = entityKey.split(':'); + if (parts.length < 3) { + return null; + } + return parts[2]; +} + +ActivePageOverlayEntityType? overlayEntityTypeForKey(EntitySyncKey entityKey) { + if (entityKey.startsWith('page:')) { + return ActivePageOverlayEntityType.pageSettings; + } + if (entityKey.startsWith('element:')) { + return ActivePageOverlayEntityType.element; + } + if (entityKey.startsWith('lineup:')) { + return ActivePageOverlayEntityType.lineup; + } + return null; +} + +EntitySyncKey? entityKeyForStrategyOp(StrategyOp op) { + switch (op.entityType) { + case StrategyOpEntityType.strategy: + return 'strategy'; + case StrategyOpEntityType.page: + final pageId = op.entityPublicId ?? op.pagePublicId; + if (pageId == null) { + return null; + } + return pageSettingsEntityKey(pageId); + case StrategyOpEntityType.element: + if (op.pagePublicId == null || op.entityPublicId == null) { + return null; + } + return elementEntityKey(op.pagePublicId!, op.entityPublicId!); + case StrategyOpEntityType.lineup: + if (op.pagePublicId == null || op.entityPublicId == null) { + return null; + } + return lineupEntityKey(op.pagePublicId!, op.entityPublicId!); + } +} + +class ActivePageOverlayEntry { + const ActivePageOverlayEntry({ + required this.entityKey, + required this.entityType, + required this.desiredPayload, + required this.desiredSortIndex, + required this.deletion, + required this.baseRevision, + required this.dirtyAt, + }); + + final EntitySyncKey entityKey; + final ActivePageOverlayEntityType entityType; + final String? desiredPayload; + final int? desiredSortIndex; + final bool deletion; + final int baseRevision; + final DateTime dirtyAt; + + ActivePageOverlayEntry copyWith({ + String? desiredPayload, + int? desiredSortIndex, + bool? deletion, + int? baseRevision, + DateTime? dirtyAt, + }) { + return ActivePageOverlayEntry( + entityKey: entityKey, + entityType: entityType, + desiredPayload: desiredPayload ?? this.desiredPayload, + desiredSortIndex: desiredSortIndex ?? this.desiredSortIndex, + deletion: deletion ?? this.deletion, + baseRevision: baseRevision ?? this.baseRevision, + dirtyAt: dirtyAt ?? this.dirtyAt, + ); + } +} + +class ProjectedPageElement { + const ProjectedPageElement({ + required this.publicId, + required this.elementType, + required this.payload, + required this.sortIndex, + }); + + final String publicId; + final String elementType; + final String payload; + final int sortIndex; +} + +class ProjectedPageLineup { + const ProjectedPageLineup({ + required this.publicId, + required this.payload, + required this.sortIndex, + }); + + final String publicId; + final String payload; + final int sortIndex; +} + +class ActivePageProjectedState { + const ActivePageProjectedState({ + required this.pageId, + required this.pageName, + required this.isAttack, + required this.settingsJson, + required this.elements, + required this.lineups, + }); + + final String pageId; + final String pageName; + final bool isAttack; + final String? settingsJson; + final List elements; + final List lineups; +} + +class QueuedEntityIntent { + const QueuedEntityIntent({ + required this.entityKey, + required this.pending, + }); + + final EntitySyncKey entityKey; + final PendingOp pending; +} + +class InFlightEntityIntent { + const InFlightEntityIntent({ + required this.entityKey, + required this.pending, + required this.sentAt, + }); + + final EntitySyncKey entityKey; + final PendingOp pending; + final DateTime sentAt; +} + +class AckedEntityIntent { + const AckedEntityIntent({ + required this.entityKey, + required this.op, + required this.ack, + }); + + final EntitySyncKey entityKey; + final StrategyOp op; + final OpAck ack; +} diff --git a/lib/providers/collab/active_page_live_sync_provider.dart b/lib/providers/collab/active_page_live_sync_provider.dart new file mode 100644 index 00000000..0fd46539 --- /dev/null +++ b/lib/providers/collab/active_page_live_sync_provider.dart @@ -0,0 +1,683 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:uuid/uuid.dart'; + +class ActivePageLiveSyncState { + const ActivePageLiveSyncState({ + this.strategyPublicId, + this.activePageId, + this.remoteBaseRevisionByEntity = const {}, + this.overlayByEntityKey = const {}, + this.lastAckBatch = const [], + }); + + final String? strategyPublicId; + final String? activePageId; + final Map remoteBaseRevisionByEntity; + final Map overlayByEntityKey; + final List lastAckBatch; + + ActivePageLiveSyncState copyWith({ + String? strategyPublicId, + String? activePageId, + bool clearActivePageId = false, + Map? remoteBaseRevisionByEntity, + Map? overlayByEntityKey, + List? lastAckBatch, + }) { + return ActivePageLiveSyncState( + strategyPublicId: strategyPublicId ?? this.strategyPublicId, + activePageId: + clearActivePageId ? null : (activePageId ?? this.activePageId), + remoteBaseRevisionByEntity: + remoteBaseRevisionByEntity ?? this.remoteBaseRevisionByEntity, + overlayByEntityKey: overlayByEntityKey ?? this.overlayByEntityKey, + lastAckBatch: lastAckBatch ?? this.lastAckBatch, + ); + } +} + +final activePageLiveSyncProvider = + NotifierProvider( + ActivePageLiveSyncNotifier.new, +); + +class ActivePageLiveSyncNotifier extends Notifier { + @override + ActivePageLiveSyncState build() { + return const ActivePageLiveSyncState(); + } + + void reset() { + state = const ActivePageLiveSyncState(); + } + + void setStateForTest(ActivePageLiveSyncState nextState) { + state = nextState; + } + + void setContext({ + required String? strategyPublicId, + required String? activePageId, + }) { + state = state.copyWith( + strategyPublicId: strategyPublicId, + activePageId: activePageId, + clearActivePageId: activePageId == null, + remoteBaseRevisionByEntity: strategyPublicId == state.strategyPublicId + ? state.remoteBaseRevisionByEntity + : const {}, + overlayByEntityKey: strategyPublicId == state.strategyPublicId + ? state.overlayByEntityKey + : const {}, + ); + } + + bool hasOverlayForPage(String pageId) { + return state.overlayByEntityKey.keys.any((key) => pageIdForEntityKey(key) == pageId); + } + + void recordAckBatch(List intents) { + state = state.copyWith(lastAckBatch: intents); + } + + Map syncLocalPage({ + required String strategyPublicId, + required String pageId, + }) { + setContext(strategyPublicId: strategyPublicId, activePageId: pageId); + final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + if (snapshot == null) { + return const {}; + } + + final queueState = ref.read(strategyOpQueueProvider); + final remoteEntities = _normalizedRemoteEntities(snapshot, pageId); + final localEntities = _normalizedLocalEntities(pageId); + final remoteRevisions = Map.from( + state.remoteBaseRevisionByEntity, + ); + + for (final entry in remoteEntities.entries) { + remoteRevisions[entry.key] = entry.value.revision; + } + + final pageKeys = { + ...remoteEntities.keys, + ...localEntities.keys, + ...state.overlayByEntityKey.keys.where((key) => pageIdForEntityKey(key) == pageId), + ...queueState.queuedByEntityKey.keys.where((key) => pageIdForEntityKey(key) == pageId), + ...queueState.inFlightByEntityKey.keys.where((key) => pageIdForEntityKey(key) == pageId), + }; + + final nextOverlay = Map.from( + state.overlayByEntityKey, + ); + + for (final key in pageKeys) { + final remote = remoteEntities[key]; + final local = localEntities[key]; + final hasQueued = queueState.queuedByEntityKey.containsKey(key); + final hasInFlight = queueState.inFlightByEntityKey.containsKey(key); + final existingOverlay = state.overlayByEntityKey[key]; + + final shouldPreserveTouched = hasQueued || hasInFlight; + final matchesRemote = _entitiesEquivalent(local, remote); + + if (matchesRemote && !hasQueued && !hasInFlight) { + if (nextOverlay.remove(key) != null) { + _debugLog('overlay.remove $key reason=matched_remote'); + } + continue; + } + + if (local == null && remote == null && !shouldPreserveTouched) { + if (nextOverlay.remove(key) != null) { + _debugLog('overlay.remove $key reason=missing_local_and_remote'); + } + continue; + } + + if (matchesRemote && shouldPreserveTouched && local != null) { + final overlay = _overlayFromDesiredEntity( + key: key, + desired: local, + baseRevision: remote?.revision ?? existingOverlay?.baseRevision ?? 0, + ); + nextOverlay[key] = overlay; + _debugLog('overlay.keep $key reason=pending_reconciliation'); + continue; + } + + if (matchesRemote && existingOverlay != null && !shouldPreserveTouched) { + nextOverlay.remove(key); + _debugLog('overlay.remove $key reason=stale_overlay_cleared'); + continue; + } + + if (local == null && remote != null) { + final overlay = ActivePageOverlayEntry( + entityKey: key, + entityType: remote.overlayEntityType, + desiredPayload: null, + desiredSortIndex: null, + deletion: true, + baseRevision: remote.revision, + dirtyAt: DateTime.now(), + ); + nextOverlay[key] = overlay; + _debugLog('overlay.upsert $key deletion=true'); + continue; + } + + if (local != null) { + final overlay = _overlayFromDesiredEntity( + key: key, + desired: local, + baseRevision: remote?.revision ?? existingOverlay?.baseRevision ?? 0, + ); + nextOverlay[key] = overlay; + _debugLog( + 'overlay.upsert $key deletion=false baseRevision=${overlay.baseRevision}', + ); + } + } + + final desiredOpsByEntityKey = {}; + for (final entry in nextOverlay.entries) { + final key = entry.key; + if (pageIdForEntityKey(key) != pageId) { + continue; + } + final remote = remoteEntities[key]; + final overlay = entry.value; + if (_overlayMatchesRemote(overlay, remote)) { + continue; + } + final op = _strategyOpFromOverlay(pageId: pageId, overlay: overlay, remote: remote); + if (op != null) { + desiredOpsByEntityKey[key] = op; + } + } + + state = state.copyWith( + strategyPublicId: strategyPublicId, + activePageId: pageId, + remoteBaseRevisionByEntity: remoteRevisions, + overlayByEntityKey: nextOverlay, + ); + + return desiredOpsByEntityKey; + } + + ActivePageProjectedState? projectPageState({ + required String strategyPublicId, + required String pageId, + }) { + setContext(strategyPublicId: strategyPublicId, activePageId: pageId); + final snapshot = ref.read(remoteStrategySnapshotProvider).valueOrNull; + if (snapshot == null) { + return null; + } + + final page = snapshot.pages.firstWhere( + (entry) => entry.publicId == pageId, + orElse: () => snapshot.pages.first, + ); + + final remoteElements = { + for (final element + in (snapshot.elementsByPage[page.publicId] ?? const [])) + if (!element.deleted) + elementEntityKey(page.publicId, element.publicId): ProjectedPageElement( + publicId: element.publicId, + elementType: element.elementType, + payload: element.payload, + sortIndex: element.sortIndex, + ), + }; + final remoteLineups = { + for (final lineup + in (snapshot.lineupsByPage[page.publicId] ?? const [])) + if (!lineup.deleted) + lineupEntityKey(page.publicId, lineup.publicId): ProjectedPageLineup( + publicId: lineup.publicId, + payload: lineup.payload, + sortIndex: lineup.sortIndex, + ), + }; + + var projectedSettingsJson = page.settings; + var projectedIsAttack = page.isAttack; + + final pageOverlays = state.overlayByEntityKey.entries.where( + (entry) => pageIdForEntityKey(entry.key) == page.publicId, + ); + for (final entry in pageOverlays) { + final overlay = entry.value; + switch (overlay.entityType) { + case ActivePageOverlayEntityType.pageSettings: + if (overlay.desiredPayload == null) { + continue; + } + final decoded = _decodeObject(overlay.desiredPayload!); + projectedSettingsJson = decoded['settings'] as String?; + final isAttack = decoded['isAttack']; + if (isAttack is bool) { + projectedIsAttack = isAttack; + } + continue; + case ActivePageOverlayEntityType.element: + if (overlay.deletion) { + remoteElements.remove(entry.key); + continue; + } + final elementId = entityIdForEntityKey(entry.key); + if (elementId == null || overlay.desiredPayload == null) { + continue; + } + final decoded = _decodeObject(overlay.desiredPayload!); + remoteElements[entry.key] = ProjectedPageElement( + publicId: elementId, + elementType: decoded['elementType'] as String? ?? 'generic', + payload: overlay.desiredPayload!, + sortIndex: overlay.desiredSortIndex ?? 0, + ); + continue; + case ActivePageOverlayEntityType.lineup: + if (overlay.deletion) { + remoteLineups.remove(entry.key); + continue; + } + final lineupId = entityIdForEntityKey(entry.key); + if (lineupId == null || overlay.desiredPayload == null) { + continue; + } + remoteLineups[entry.key] = ProjectedPageLineup( + publicId: lineupId, + payload: overlay.desiredPayload!, + sortIndex: overlay.desiredSortIndex ?? 0, + ); + continue; + } + } + + _debugLog( + 'projected.rehydrate page=${page.publicId} overlays=${pageOverlays.length}', + ); + + return ActivePageProjectedState( + pageId: page.publicId, + pageName: page.name, + isAttack: projectedIsAttack, + settingsJson: projectedSettingsJson, + elements: remoteElements.values.toList(growable: false) + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)), + lineups: remoteLineups.values.toList(growable: false) + ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)), + ); + } + + Map _decodeObject(String payload) { + final decoded = jsonDecode(payload); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return Map.from(decoded); + } + return const {}; + } + + Map _normalizedRemoteEntities( + RemoteStrategySnapshot snapshot, + String pageId, + ) { + final page = snapshot.pages.firstWhere( + (entry) => entry.publicId == pageId, + orElse: () => snapshot.pages.first, + ); + + final entities = { + pageSettingsEntityKey(page.publicId): _NormalizedEntity( + key: pageSettingsEntityKey(page.publicId), + overlayEntityType: ActivePageOverlayEntityType.pageSettings, + payload: _pagePayload( + settingsJson: page.settings, + isAttack: page.isAttack, + ), + sortIndex: null, + revision: page.revision, + deleted: false, + ), + }; + + for (final element + in (snapshot.elementsByPage[page.publicId] ?? const [])) { + if (element.deleted) { + continue; + } + final key = elementEntityKey(page.publicId, element.publicId); + entities[key] = _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: element.payload, + sortIndex: element.sortIndex, + revision: element.revision, + deleted: false, + ); + } + + for (final lineup + in (snapshot.lineupsByPage[page.publicId] ?? const [])) { + if (lineup.deleted) { + continue; + } + final key = lineupEntityKey(page.publicId, lineup.publicId); + entities[key] = _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: lineup.payload, + sortIndex: lineup.sortIndex, + revision: lineup.revision, + deleted: false, + ); + } + + return entities; + } + + Map _normalizedLocalEntities(String pageId) { + final entities = {}; + + final pageKey = pageSettingsEntityKey(pageId); + entities[pageKey] = _NormalizedEntity( + key: pageKey, + overlayEntityType: ActivePageOverlayEntityType.pageSettings, + payload: _pagePayload( + settingsJson: ref.read(strategySettingsProvider.notifier).toJson(), + isAttack: ref.read(mapProvider).isAttack, + ), + sortIndex: null, + revision: 0, + deleted: false, + ); + + final elementEnvelopes = _collectLocalElementEnvelopes(); + for (var index = 0; index < elementEnvelopes.length; index++) { + final envelope = elementEnvelopes[index]; + final key = elementEntityKey(pageId, envelope.publicId); + entities[key] = _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: jsonEncode(envelope.payload), + sortIndex: index, + revision: 0, + deleted: false, + ); + } + + final lineups = ref.read(lineUpProvider).lineUps; + for (var index = 0; index < lineups.length; index++) { + final lineup = lineups[index]; + final key = lineupEntityKey(pageId, lineup.id); + entities[key] = _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: jsonEncode(lineup.toJson()), + sortIndex: index, + revision: 0, + deleted: false, + ); + } + + return entities; + } + + List<_CollabElementEnvelope> _collectLocalElementEnvelopes() { + final envelopes = <_CollabElementEnvelope>[]; + + for (final agent in ref.read(agentProvider)) { + final payload = Map.from(agent.toJson()) + ..putIfAbsent('elementType', () => 'agent'); + envelopes.add( + _CollabElementEnvelope( + publicId: agent.id, + payload: payload, + ), + ); + } + + for (final ability in ref.read(abilityProvider)) { + final payload = Map.from(ability.toJson()) + ..putIfAbsent('elementType', () => 'ability'); + envelopes.add( + _CollabElementEnvelope( + publicId: ability.id, + payload: payload, + ), + ); + } + + for (final drawing in ref.read(drawingProvider).elements) { + final encoded = jsonDecode(DrawingProvider.objectToJson([drawing])) as List; + final payload = Map.from( + (encoded.isEmpty ? {} : encoded.first) as Map, + )..putIfAbsent('elementType', () => 'drawing'); + envelopes.add( + _CollabElementEnvelope( + publicId: drawing.id, + payload: payload, + ), + ); + } + + for (final text in ref.read(textProvider)) { + final payload = Map.from(text.toJson()) + ..putIfAbsent('elementType', () => 'text'); + envelopes.add( + _CollabElementEnvelope( + publicId: text.id, + payload: payload, + ), + ); + } + + for (final image in ref.read(placedImageProvider).images) { + final payload = Map.from(image.toJson()) + ..putIfAbsent('elementType', () => 'image'); + envelopes.add( + _CollabElementEnvelope( + publicId: image.id, + payload: payload, + ), + ); + } + + for (final utility in ref.read(utilityProvider)) { + final payload = Map.from(utility.toJson()) + ..putIfAbsent('elementType', () => 'utility'); + envelopes.add( + _CollabElementEnvelope( + publicId: utility.id, + payload: payload, + ), + ); + } + + return envelopes; + } + + ActivePageOverlayEntry _overlayFromDesiredEntity({ + required EntitySyncKey key, + required _NormalizedEntity desired, + required int baseRevision, + }) { + return ActivePageOverlayEntry( + entityKey: key, + entityType: desired.overlayEntityType, + desiredPayload: desired.payload, + desiredSortIndex: desired.sortIndex, + deletion: desired.deleted, + baseRevision: baseRevision, + dirtyAt: DateTime.now(), + ); + } + + StrategyOp? _strategyOpFromOverlay({ + required String pageId, + required ActivePageOverlayEntry overlay, + required _NormalizedEntity? remote, + }) { + final entityId = entityIdForEntityKey(overlay.entityKey); + switch (overlay.entityType) { + case ActivePageOverlayEntityType.pageSettings: + return StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.page, + entityPublicId: pageId, + payload: overlay.desiredPayload, + expectedRevision: remote?.revision ?? overlay.baseRevision, + ); + case ActivePageOverlayEntityType.element: + if (entityId == null) { + return null; + } + if (overlay.deletion) { + return StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.delete, + entityType: StrategyOpEntityType.element, + entityPublicId: entityId, + pagePublicId: pageId, + expectedRevision: remote?.revision ?? overlay.baseRevision, + ); + } + return StrategyOp( + opId: const Uuid().v4(), + kind: remote == null ? StrategyOpKind.add : StrategyOpKind.patch, + entityType: StrategyOpEntityType.element, + entityPublicId: entityId, + pagePublicId: pageId, + payload: overlay.desiredPayload, + sortIndex: overlay.desiredSortIndex, + expectedRevision: remote == null ? null : (remote.revision), + ); + case ActivePageOverlayEntityType.lineup: + if (entityId == null) { + return null; + } + if (overlay.deletion) { + return StrategyOp( + opId: const Uuid().v4(), + kind: StrategyOpKind.delete, + entityType: StrategyOpEntityType.lineup, + entityPublicId: entityId, + pagePublicId: pageId, + expectedRevision: remote?.revision ?? overlay.baseRevision, + ); + } + return StrategyOp( + opId: const Uuid().v4(), + kind: remote == null ? StrategyOpKind.add : StrategyOpKind.patch, + entityType: StrategyOpEntityType.lineup, + entityPublicId: entityId, + pagePublicId: pageId, + payload: overlay.desiredPayload, + sortIndex: overlay.desiredSortIndex, + expectedRevision: remote == null ? null : remote.revision, + ); + } + } + + bool _overlayMatchesRemote( + ActivePageOverlayEntry overlay, + _NormalizedEntity? remote, + ) { + if (overlay.deletion) { + return remote == null; + } + if (remote == null) { + return false; + } + return overlay.desiredPayload == remote.payload && + overlay.desiredSortIndex == remote.sortIndex; + } + + bool _entitiesEquivalent( + _NormalizedEntity? local, + _NormalizedEntity? remote, + ) { + if (identical(local, remote)) { + return true; + } + if (local == null || remote == null) { + return false; + } + return local.deleted == remote.deleted && + local.payload == remote.payload && + local.sortIndex == remote.sortIndex && + local.overlayEntityType == remote.overlayEntityType; + } + + String _pagePayload({ + required String? settingsJson, + required bool isAttack, + }) { + return jsonEncode({ + 'settings': settingsJson, + 'isAttack': isAttack, + }); + } + + void _debugLog(String message) { + assert(() { + log(message, name: 'active_page_live_sync'); + return true; + }()); + } +} + +class _NormalizedEntity { + const _NormalizedEntity({ + required this.key, + required this.overlayEntityType, + required this.payload, + required this.sortIndex, + required this.revision, + required this.deleted, + }); + + final EntitySyncKey key; + final ActivePageOverlayEntityType overlayEntityType; + final String payload; + final int? sortIndex; + final int revision; + final bool deleted; +} + +class _CollabElementEnvelope { + const _CollabElementEnvelope({ + required this.publicId, + required this.payload, + }); + + final String publicId; + final Map payload; +} diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index af596efe..61658364 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -3,10 +3,12 @@ import 'dart:developer'; import 'dart:math' as math; import 'package:convex_flutter/convex_flutter.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/collab/collab_models.dart'; import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/cloud_collab_provider.dart'; import 'package:uuid/uuid.dart'; @@ -14,39 +16,52 @@ class StrategyOpQueueState { const StrategyOpQueueState({ this.strategyPublicId, this.clientId, - this.pending = const [], + this.queuedByEntityKey = const {}, + this.inFlightByEntityKey = const {}, this.isFlushing = false, this.lastError, this.lastFlushAt, this.lastAcks = const [], + this.lastAckBatch = const [], }); final String? strategyPublicId; final String? clientId; - final List pending; + final Map queuedByEntityKey; + final Map inFlightByEntityKey; final bool isFlushing; final String? lastError; final DateTime? lastFlushAt; final List lastAcks; + final List lastAckBatch; + + List get pending => [ + ...queuedByEntityKey.values.map((intent) => intent.pending), + ...inFlightByEntityKey.values.map((intent) => intent.pending), + ]; StrategyOpQueueState copyWith({ String? strategyPublicId, String? clientId, - List? pending, + Map? queuedByEntityKey, + Map? inFlightByEntityKey, bool? isFlushing, String? lastError, bool clearError = false, DateTime? lastFlushAt, List? lastAcks, + List? lastAckBatch, }) { return StrategyOpQueueState( strategyPublicId: strategyPublicId ?? this.strategyPublicId, clientId: clientId ?? this.clientId, - pending: pending ?? this.pending, + queuedByEntityKey: queuedByEntityKey ?? this.queuedByEntityKey, + inFlightByEntityKey: inFlightByEntityKey ?? this.inFlightByEntityKey, isFlushing: isFlushing ?? this.isFlushing, lastError: clearError ? null : (lastError ?? this.lastError), lastFlushAt: lastFlushAt ?? this.lastFlushAt, lastAcks: lastAcks ?? this.lastAcks, + lastAckBatch: lastAckBatch ?? this.lastAckBatch, ); } } @@ -77,149 +92,178 @@ class StrategyOpQueueNotifier extends Notifier { } void setActiveStrategy(String? strategyPublicId) { - if (state.strategyPublicId == strategyPublicId) return; + if (state.strategyPublicId == strategyPublicId) { + return; + } _debounceTimer?.cancel(); state = state.copyWith( strategyPublicId: strategyPublicId, clientId: const Uuid().v4(), - pending: const [], - lastAcks: const [], + queuedByEntityKey: const {}, + inFlightByEntityKey: const {}, + lastAcks: const [], + lastAckBatch: const [], clearError: true, ); } void enqueue(StrategyOp op, {bool flushImmediately = false}) { - final currentStrategyId = state.strategyPublicId; - if (currentStrategyId == null) { + final entityKey = entityKeyForStrategyOp(op); + if (entityKey == null) { + return; + } + final pageId = pageIdForEntityKey(entityKey); + if (pageId != null) { + syncDesiredOpsForPage( + pageId: pageId, + desiredOpsByEntityKey: {entityKey: op}, + clearMissing: false, + flushImmediately: flushImmediately, + ); return; } - final incoming = PendingOp( - op: op, - clientId: state.clientId ?? const Uuid().v4(), - attempts: 0, + final queued = Map.from( + state.queuedByEntityKey, + ); + queued[entityKey] = QueuedEntityIntent( + entityKey: entityKey, + pending: PendingOp( + op: op, + clientId: state.clientId ?? const Uuid().v4(), + ), ); - final mergedPending = _mergePending(state.pending, incoming); - state = state.copyWith( - pending: mergedPending, + queuedByEntityKey: queued, clearError: true, ); - - if (flushImmediately) { - unawaited(flushNow()); - return; - } - - _debounceTimer?.cancel(); - _debounceTimer = Timer(_debounceDelay, () { - unawaited(flushNow()); - }); + _scheduleFlush(flushImmediately: flushImmediately); } - List _mergePending(List pending, PendingOp incoming) { - final entityKey = _entityKeyForOp(incoming.op); - if (entityKey == null) { - return [...pending, incoming]; - } - - final merged = []; - var handled = false; + void enqueueAll(Iterable ops, {bool flushImmediately = false}) { + final opsByPage = >{}; + final genericQueued = Map.from( + state.queuedByEntityKey, + ); - for (final existing in pending) { - if (handled || _entityKeyForOp(existing.op) != entityKey) { - merged.add(existing); + for (final op in ops) { + final entityKey = entityKeyForStrategyOp(op); + if (entityKey == null) { continue; } - - final replacement = _mergePendingOp(existing, incoming); - if (replacement != null) { - merged.add(replacement); + final pageId = pageIdForEntityKey(entityKey); + if (pageId == null) { + genericQueued[entityKey] = QueuedEntityIntent( + entityKey: entityKey, + pending: PendingOp( + op: op, + clientId: state.clientId ?? const Uuid().v4(), + ), + ); + continue; } - handled = true; + opsByPage.putIfAbsent(pageId, () => {})[entityKey] = + op; } - if (!handled) { - merged.add(incoming); + if (!mapEquals(genericQueued, state.queuedByEntityKey)) { + state = state.copyWith( + queuedByEntityKey: genericQueued, + clearError: true, + ); } - return merged; + for (final entry in opsByPage.entries) { + syncDesiredOpsForPage( + pageId: entry.key, + desiredOpsByEntityKey: entry.value, + clearMissing: false, + flushImmediately: false, + ); + } + _scheduleFlush(flushImmediately: flushImmediately); } - String? _entityKeyForOp(StrategyOp op) { - switch (op.entityType) { - case StrategyOpEntityType.strategy: - return 'strategy'; - case StrategyOpEntityType.page: - return op.entityPublicId == null ? null : 'page:${op.entityPublicId}'; - case StrategyOpEntityType.element: - if (op.pagePublicId == null || op.entityPublicId == null) { - return null; + void syncDesiredOpsForPage({ + required String pageId, + required Map desiredOpsByEntityKey, + bool clearMissing = true, + bool flushImmediately = false, + }) { + final queued = Map.from( + state.queuedByEntityKey, + ); + final pageKeys = clearMissing + ? { + ...queued.keys.where((key) => pageIdForEntityKey(key) == pageId), + ...desiredOpsByEntityKey.keys, + } + : desiredOpsByEntityKey.keys.toSet(); + + var changed = false; + for (final key in pageKeys) { + final desired = desiredOpsByEntityKey[key]; + final existingQueued = queued[key]; + final inFlight = state.inFlightByEntityKey[key]?.pending.op; + + if (desired == null) { + if (queued.remove(key) != null) { + changed = true; + _debugLog('queued.drop $key reason=returned_to_remote_base'); } - return 'element:${op.pagePublicId}:${op.entityPublicId}'; - case StrategyOpEntityType.lineup: - if (op.pagePublicId == null || op.entityPublicId == null) { - return null; + continue; + } + + if (inFlight != null && _sameIntent(desired, inFlight)) { + if (queued.remove(key) != null) { + changed = true; + _debugLog('queued.drop $key reason=covered_by_in_flight'); } - return 'lineup:${op.pagePublicId}:${op.entityPublicId}'; - } - } + continue; + } - PendingOp? _mergePendingOp(PendingOp existing, PendingOp incoming) { - final existingOp = existing.op; - final incomingOp = incoming.op; + if (existingQueued != null && _sameIntent(existingQueued.pending.op, desired)) { + continue; + } - if (incomingOp.kind == StrategyOpKind.delete && - existingOp.kind == StrategyOpKind.add) { - return null; - } + final mergedDesired = existingQueued == null + ? desired + : _mergeQueuedIntent(existingQueued.pending.op, desired); + if (mergedDesired == null) { + if (queued.remove(key) != null) { + changed = true; + _debugLog('queued.drop $key reason=coalesced_to_noop'); + } + continue; + } - if (existingOp.kind == StrategyOpKind.add && - incomingOp.kind == StrategyOpKind.patch) { - return PendingOp( - op: StrategyOp( - opId: existingOp.opId, - kind: StrategyOpKind.add, - entityType: existingOp.entityType, - entityPublicId: existingOp.entityPublicId, - pagePublicId: existingOp.pagePublicId, - payload: incomingOp.payload ?? existingOp.payload, - sortIndex: incomingOp.sortIndex ?? existingOp.sortIndex, - expectedRevision: existingOp.expectedRevision, - expectedSequence: existingOp.expectedSequence, + queued[key] = QueuedEntityIntent( + entityKey: key, + pending: PendingOp( + op: mergedDesired, + clientId: state.clientId ?? const Uuid().v4(), + attempts: existingQueued?.pending.attempts ?? 0, + lastAttemptAt: existingQueued?.pending.lastAttemptAt, ), - clientId: existing.clientId, - attempts: existing.attempts, - lastAttemptAt: existing.lastAttemptAt, + ); + changed = true; + _debugLog( + existingQueued == null + ? 'queued.upsert $key kind=${mergedDesired.kind.name}' + : 'queued.replace $key kind=${mergedDesired.kind.name}', ); } - return PendingOp( - op: StrategyOp( - opId: existingOp.opId, - kind: incomingOp.kind, - entityType: incomingOp.entityType, - entityPublicId: incomingOp.entityPublicId ?? existingOp.entityPublicId, - pagePublicId: incomingOp.pagePublicId ?? existingOp.pagePublicId, - payload: incomingOp.payload ?? existingOp.payload, - sortIndex: incomingOp.sortIndex ?? existingOp.sortIndex, - expectedRevision: incomingOp.expectedRevision ?? existingOp.expectedRevision, - expectedSequence: incomingOp.expectedSequence ?? existingOp.expectedSequence, - ), - clientId: existing.clientId, - attempts: existing.attempts, - lastAttemptAt: existing.lastAttemptAt, - ); - } - - void enqueueAll(Iterable ops, {bool flushImmediately = false}) { - for (final op in ops) { - enqueue(op, flushImmediately: false); - } - if (flushImmediately) { - unawaited(flushNow()); + if (!changed) { + return; } + + state = state.copyWith( + queuedByEntityKey: queued, + clearError: true, + ); + _scheduleFlush(flushImmediately: flushImmediately); } Future flushNow() async { @@ -228,7 +272,7 @@ class StrategyOpQueueNotifier extends Notifier { } final strategyPublicId = state.strategyPublicId; - if (strategyPublicId == null || state.pending.isEmpty) { + if (strategyPublicId == null || state.queuedByEntityKey.isEmpty) { return; } @@ -248,62 +292,105 @@ class StrategyOpQueueNotifier extends Notifier { } if (!auth.isAuthenticated || !auth.isConvexUserReady || !isConnected) { - final incremented = [ - for (final pending in state.pending) pending.incrementAttempt(), - ]; + final incremented = { + for (final entry in state.queuedByEntityKey.entries) + entry.key: QueuedEntityIntent( + entityKey: entry.key, + pending: entry.value.pending.incrementAttempt(), + ), + }; state = state.copyWith( - pending: incremented, + queuedByEntityKey: incremented, lastError: !auth.isAuthenticated ? 'Not authenticated for cloud sync.' : (!auth.isConvexUserReady ? 'Cloud user setup is not ready.' : 'Cloud connection is offline.'), ); - _scheduleRetry(incremented); + _scheduleRetry(incremented.values.map((intent) => intent.pending).toList()); + return; + } + + final batch = state.queuedByEntityKey.values + .where((intent) => !state.inFlightByEntityKey.containsKey(intent.entityKey)) + .take(_maxBatchSize) + .toList(growable: false); + if (batch.isEmpty) { return; } - state = state.copyWith(isFlushing: true, clearError: true); + final queued = Map.from( + state.queuedByEntityKey, + ); + final inFlight = Map.from( + state.inFlightByEntityKey, + ); + final sentAt = DateTime.now(); + final batchByOpId = {}; + for (final intent in batch) { + queued.remove(intent.entityKey); + inFlight[intent.entityKey] = InFlightEntityIntent( + entityKey: intent.entityKey, + pending: intent.pending, + sentAt: sentAt, + ); + batchByOpId[intent.pending.op.opId] = intent; + _debugLog('inflight.send ${intent.entityKey} op=${intent.pending.op.opId}'); + } - final batch = state.pending.take(_maxBatchSize).toList(growable: false); - final ops = batch.map((pending) => pending.op).toList(growable: false); + state = state.copyWith( + queuedByEntityKey: queued, + inFlightByEntityKey: inFlight, + isFlushing: true, + clearError: true, + ); try { final acks = await _repo.applyBatch( strategyPublicId: strategyPublicId, clientId: state.clientId ?? const Uuid().v4(), - ops: ops, + ops: batch.map((intent) => intent.pending.op).toList(growable: false), ); - final rejected = {}; - for (final pending in batch) { - rejected[pending.op.opId] = pending; - } - + final latestQueued = Map.from( + state.queuedByEntityKey, + ); + final latestInFlight = Map.from( + state.inFlightByEntityKey, + ); + final acked = []; for (final ack in acks) { - if (ack.isAck) { - rejected.remove(ack.opId); + final sent = batchByOpId[ack.opId]; + if (sent == null) { continue; } + latestInFlight.remove(sent.entityKey); + acked.add( + AckedEntityIntent( + entityKey: sent.entityKey, + op: sent.pending.op, + ack: ack, + ), + ); + _debugLog( + 'inflight.${ack.isAck ? 'ack' : 'reject'} ${sent.entityKey} op=${ack.opId}', + ); + } - final pending = rejected[ack.opId]; - if (pending != null) { - rejected[ack.opId] = pending.incrementAttempt(); - } + for (final sent in batch) { + latestInFlight.remove(sent.entityKey); } - final untouched = state.pending.skip(batch.length).toList(growable: false); - final retried = rejected.values.toList(growable: false); state = state.copyWith( - pending: [...retried, ...untouched], + queuedByEntityKey: latestQueued, + inFlightByEntityKey: latestInFlight, isFlushing: false, lastFlushAt: DateTime.now(), lastAcks: acks, + lastAckBatch: acked, ); - if (rejected.isNotEmpty) { - _scheduleRetry(retried); - } else if (untouched.isNotEmpty) { + if (state.queuedByEntityKey.isNotEmpty) { unawaited(flushNow()); } } catch (error, stackTrace) { @@ -315,32 +402,73 @@ class StrategyOpQueueNotifier extends Notifier { stackTrace: stackTrace, ), ); - state = state.copyWith( - isFlushing: false, + _restoreBatchAfterFailure( + batch, lastError: 'Cloud authentication expired. Retry required.', ); return; } - log('Failed flushing op queue: $error', - error: error, stackTrace: stackTrace); + log( + 'Failed flushing op queue: $error', + error: error, + stackTrace: stackTrace, + ); + _restoreBatchAfterFailure(batch, lastError: '$error'); + } + } - final incremented = [ - for (final pending in state.pending) pending.incrementAttempt(), - ]; + void _restoreBatchAfterFailure( + List batch, { + required String lastError, + }) { + final queued = Map.from( + state.queuedByEntityKey, + ); + final inFlight = Map.from( + state.inFlightByEntityKey, + ); + final retried = []; - state = state.copyWith( - pending: incremented, - isFlushing: false, - lastError: '$error', + for (final sent in batch) { + inFlight.remove(sent.entityKey); + if (queued.containsKey(sent.entityKey)) { + continue; + } + final retriedPending = sent.pending.incrementAttempt(); + queued[sent.entityKey] = QueuedEntityIntent( + entityKey: sent.entityKey, + pending: retriedPending, ); + retried.add(retriedPending); + _debugLog('queued.retry ${sent.entityKey} reason=flush_failure'); + } + + state = state.copyWith( + queuedByEntityKey: queued, + inFlightByEntityKey: inFlight, + isFlushing: false, + lastError: lastError, + ); + _scheduleRetry(retried); + } - _scheduleRetry(incremented); + void _scheduleFlush({required bool flushImmediately}) { + if (flushImmediately) { + unawaited(flushNow()); + return; } + + _debounceTimer?.cancel(); + _debounceTimer = Timer(_debounceDelay, () { + unawaited(flushNow()); + }); } void _scheduleRetry(List pending) { - if (pending.isEmpty) return; + if (pending.isEmpty) { + return; + } final maxAttempt = pending.fold( 0, @@ -354,4 +482,55 @@ class StrategyOpQueueNotifier extends Notifier { unawaited(flushNow()); }); } + + bool _sameIntent(StrategyOp left, StrategyOp right) { + return left.kind == right.kind && + left.entityType == right.entityType && + left.entityPublicId == right.entityPublicId && + left.pagePublicId == right.pagePublicId && + left.payload == right.payload && + left.sortIndex == right.sortIndex && + left.expectedRevision == right.expectedRevision && + left.expectedSequence == right.expectedSequence; + } + + StrategyOp? _mergeQueuedIntent(StrategyOp existing, StrategyOp desired) { + if (desired.kind == StrategyOpKind.delete && + existing.kind == StrategyOpKind.add) { + return null; + } + + if (existing.kind == StrategyOpKind.add && desired.kind == StrategyOpKind.patch) { + return StrategyOp( + opId: existing.opId, + kind: StrategyOpKind.add, + entityType: existing.entityType, + entityPublicId: existing.entityPublicId, + pagePublicId: existing.pagePublicId, + payload: desired.payload ?? existing.payload, + sortIndex: desired.sortIndex ?? existing.sortIndex, + expectedRevision: existing.expectedRevision, + expectedSequence: existing.expectedSequence, + ); + } + + return StrategyOp( + opId: existing.opId, + kind: desired.kind, + entityType: desired.entityType, + entityPublicId: desired.entityPublicId ?? existing.entityPublicId, + pagePublicId: desired.pagePublicId ?? existing.pagePublicId, + payload: desired.payload ?? existing.payload, + sortIndex: desired.sortIndex ?? existing.sortIndex, + expectedRevision: desired.expectedRevision ?? existing.expectedRevision, + expectedSequence: desired.expectedSequence ?? existing.expectedSequence, + ); + } + + void _debugLog(String message) { + assert(() { + log(message, name: 'strategy_op_queue'); + return true; + }()); + } } diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index 901ff654..c8d900dc 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -12,6 +12,8 @@ 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/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_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'; @@ -139,11 +141,12 @@ class StrategyPageSessionNotifier extends Notifier { }); ref.listen(strategyOpQueueProvider, (previous, next) { - final previousAcks = previous?.lastAcks ?? const []; - if (next.lastAcks.isEmpty || identical(previousAcks, next.lastAcks)) { + final previousAckBatch = previous?.lastAckBatch ?? const []; + if (next.lastAckBatch.isEmpty || + identical(previousAckBatch, next.lastAckBatch)) { return; } - unawaited(_reconcileAcks(next.lastAcks)); + unawaited(_reconcileAcks(next.lastAcks, next.lastAckBatch)); }); return const StrategyPageSessionState( @@ -175,6 +178,10 @@ class StrategyPageSessionNotifier extends Notifier { transitionState: PageTransitionState.idle, isApplyingPage: false, ); + ref.read(activePageLiveSyncProvider.notifier).setContext( + strategyPublicId: strategyId, + activePageId: selected, + ); if (selected != null) { await _rehydrateActivePageFromSource(selected); @@ -243,6 +250,7 @@ class StrategyPageSessionNotifier extends Notifier { transitionNotifier.complete(); } state = state.copyWith(transitionState: PageTransitionState.idle); + _resumePendingRemoteReapplyIfPossible(); }); } @@ -303,6 +311,7 @@ class StrategyPageSessionNotifier extends Notifier { _lastHydratedRemoteStrategyId = null; _lastHydratedRemotePageId = null; _pendingRemoteReapply = false; + ref.read(activePageLiveSyncProvider.notifier).reset(); } Future _switchToPage( @@ -321,6 +330,10 @@ class StrategyPageSessionNotifier extends Notifier { await pageSource.flushCurrentPage(); if (source == StrategySource.cloud) { await ref.read(strategyOpQueueProvider.notifier).flushNow(); + ref.read(activePageLiveSyncProvider.notifier).setContext( + strategyPublicId: strategyId, + activePageId: pageId, + ); } final pageData = await pageSource.loadPage(pageId); @@ -343,8 +356,11 @@ class StrategyPageSessionNotifier extends Notifier { return; } - final pageData = - await _resolvePageSource(strategyId, source).loadPage(pageId); + ref.read(activePageLiveSyncProvider.notifier).setContext( + strategyPublicId: strategyId, + activePageId: pageId, + ); + final pageData = await _resolvePageSource(strategyId, source).loadPage(pageId); await _applyLoadedPageData( pageData, strategyId: strategyId, @@ -385,6 +401,7 @@ class StrategyPageSessionNotifier extends Notifier { activePageId: pageData.pageId, isApplyingPage: false, ); + _resumePendingRemoteReapplyIfPossible(); } } @@ -455,12 +472,8 @@ class StrategyPageSessionNotifier extends Notifier { } bool _canSafelyReapplyRemotePage() { - final saveState = ref.read(strategySaveStateProvider); return !state.isApplyingPage && - state.transitionState == PageTransitionState.idle && - !saveState.isDirty && - !saveState.hasPendingCloudSync && - !saveState.isSaving; + state.transitionState == PageTransitionState.idle; } String? _resolveHydrationTargetPage(RemoteStrategySnapshot snapshot) { @@ -489,12 +502,16 @@ class StrategyPageSessionNotifier extends Notifier { _lastHydratedRemotePageId = pageId; } - Future _reconcileAcks(List acks) async { + 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) { @@ -525,18 +542,41 @@ class StrategyPageSessionNotifier extends Notifier { ); } - if (!hasReject) { - return; - } - await ref.read(remoteStrategySnapshotProvider.notifier).refresh(); - if (_canSafelyReapplyRemotePage() && state.activePageId != null) { - await _rehydrateActivePageFromSource(state.activePageId!); - } else { + final activePageId = state.activePageId; + final strategyId = strategyState.strategyId; + if (activePageId != null && strategyId != null) { + final desiredOpsByEntityKey = + ref.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: strategyId, + pageId: activePageId, + ); + ref.read(strategyOpQueueProvider.notifier).syncDesiredOpsForPage( + pageId: activePageId, + desiredOpsByEntityKey: desiredOpsByEntityKey, + flushImmediately: false, + ); + if (_canSafelyReapplyRemotePage()) { + await _rehydrateActivePageFromSource(activePageId); + } else { + _pendingRemoteReapply = true; + } + } else if (hasReject) { _pendingRemoteReapply = true; } } + void _resumePendingRemoteReapplyIfPossible() { + if (!_pendingRemoteReapply || !_canSafelyReapplyRemotePage()) { + return; + } + _pendingRemoteReapply = false; + final pageId = state.activePageId; + if (pageId != null) { + unawaited(_rehydrateActivePageFromSource(pageId)); + } + } + Map _snapshotAllPlaced() { final map = {}; for (final agent in ref.read(agentProvider)) { diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 50e8653b..3bdd4242 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -212,6 +212,10 @@ class StrategyProvider extends Notifier { } if (_currentStrategyIsCloud()) { + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); unawaited(notifyCloudMutation(flushImmediately: false)); return; } diff --git a/lib/strategy/strategy_page_source.dart b/lib/strategy/strategy_page_source.dart index 3cbec041..3f2f9ad5 100644 --- a/lib/strategy/strategy_page_source.dart +++ b/lib/strategy/strategy_page_source.dart @@ -10,6 +10,8 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_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/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; @@ -22,7 +24,6 @@ import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/strategy/strategy_migrator.dart'; import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/strategy/strategy_page_models.dart'; -import 'package:uuid/uuid.dart'; abstract class StrategyPageSource { Future> listPageIds(); @@ -167,6 +168,16 @@ class CloudStrategyPageSource implements StrategyPageSource { orElse: () => pages.first, ); + final projected = ref.read(activePageLiveSyncProvider.notifier).projectPageState( + strategyPublicId: strategyId, + pageId: page.publicId, + ); + if (projected != null && + (page.publicId == activePageId() || + ref.read(activePageLiveSyncProvider.notifier).hasOverlayForPage(page.publicId))) { + return _hydrateProjectedPage(snapshot, page, projected); + } + final elements = snapshot.elementsByPage[page.publicId] ?? const []; final lineups = snapshot.lineupsByPage[page.publicId] ?? const []; @@ -266,255 +277,122 @@ class CloudStrategyPageSource implements StrategyPageSource { return; } - final ops = _buildOpsFromCurrentPageSnapshot(pageId); - if (ops.isEmpty) { - return; - } - - ref - .read(strategyOpQueueProvider.notifier) - .enqueueAll(ops, flushImmediately: false); + final desiredOpsByEntityKey = + ref.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: strategyId, + pageId: pageId, + ); + ref.read(strategyOpQueueProvider.notifier).syncDesiredOpsForPage( + pageId: pageId, + desiredOpsByEntityKey: desiredOpsByEntityKey, + flushImmediately: false, + ); } - List<_CollabElementEnvelope> _collectLocalElementEnvelopes() { - final envelopes = <_CollabElementEnvelope>[]; - - for (final agent in ref.read(agentProvider)) { - final payload = Map.from(agent.toJson()) - ..putIfAbsent('elementType', () => 'agent'); - envelopes.add( - _CollabElementEnvelope( - publicId: agent.id, - elementType: 'agent', - payload: payload, - ), - ); - } - - for (final ability in ref.read(abilityProvider)) { - final payload = Map.from(ability.toJson()) - ..putIfAbsent('elementType', () => 'ability'); - envelopes.add( - _CollabElementEnvelope( - publicId: ability.id, - elementType: 'ability', - payload: payload, - ), - ); - } + StrategyEditorPageData _hydrateProjectedPage( + RemoteStrategySnapshot snapshot, + RemotePage page, + ActivePageProjectedState projected, + ) { + final agents = []; + final abilities = []; + final drawings = []; + final texts = []; + final images = []; + final utilities = []; - for (final drawing in ref.read(drawingProvider).elements) { - final encoded = jsonDecode(DrawingProvider.objectToJson([drawing])) as List; - final payload = Map.from( - (encoded.isEmpty ? {} : encoded.first) as Map, - )..putIfAbsent('elementType', () => 'drawing'); - envelopes.add( - _CollabElementEnvelope( - publicId: drawing.id, - elementType: 'drawing', - payload: payload, - ), - ); + for (final element in projected.elements) { + final payload = _decodeJsonObject(element.payload); + try { + switch (element.elementType) { + case 'agent': + agents.add(PlacedAgentNode.fromJson(payload)); + break; + case 'ability': + abilities.add(PlacedAbility.fromJson(payload)); + break; + case 'drawing': + final decoded = DrawingProvider.fromJson(jsonEncode([payload])); + if (decoded.isNotEmpty) { + drawings.add(decoded.first); + } + break; + case 'text': + texts.add(PlacedText.fromJson(payload)); + break; + case 'image': + images.add(PlacedImage.fromJson(payload)); + break; + case 'utility': + utilities.add(PlacedUtility.fromJson(payload)); + break; + } + } catch (_) { + // Ignore malformed payloads during hydration. + } } - for (final text in ref.read(textProvider)) { - final payload = Map.from(text.toJson()) - ..putIfAbsent('elementType', () => 'text'); - envelopes.add( - _CollabElementEnvelope( - publicId: text.id, - elementType: 'text', - payload: payload, - ), - ); + final parsedLineups = []; + for (final lineup in projected.lineups) { + try { + final decoded = jsonDecode(lineup.payload); + if (decoded is Map) { + parsedLineups.add(LineUp.fromJson(decoded)); + } else if (decoded is Map) { + parsedLineups.add(LineUp.fromJson(Map.from(decoded))); + } + } catch (_) { + // Ignore malformed payloads during hydration. + } } - for (final image in ref.read(placedImageProvider).images) { - final payload = Map.from(image.toJson()) - ..putIfAbsent('elementType', () => 'image'); - envelopes.add( - _CollabElementEnvelope( - publicId: image.id, - elementType: 'image', - payload: payload, - ), - ); - } + final mapValue = Maps.mapNames.entries.firstWhere( + (entry) => entry.value == snapshot.header.mapData, + orElse: () => const MapEntry(MapValue.ascent, 'ascent'), + ); - for (final utility in ref.read(utilityProvider)) { - final payload = Map.from(utility.toJson()) - ..putIfAbsent('elementType', () => 'utility'); - envelopes.add( - _CollabElementEnvelope( - publicId: utility.id, - elementType: 'utility', - payload: payload, - ), - ); - } + final settings = _parsePageSettings(projected.settingsJson); - return envelopes; + return StrategyEditorPageData( + pageId: projected.pageId, + pageName: page.name, + isAttack: projected.isAttack, + map: mapValue.key, + settings: settings, + agents: agents, + abilities: abilities, + drawings: drawings, + texts: texts, + images: images, + utilities: utilities, + lineups: parsedLineups, + ); } - List _buildOpsFromCurrentPageSnapshot(String pageId) { - final remoteElements = _snapshot.elementsByPage[pageId] ?? const []; - final remoteById = { - for (final element in remoteElements) element.publicId: element, - }; - - final local = _collectLocalElementEnvelopes(); - final localById = { - for (var i = 0; i < local.length; i++) local[i].publicId: (local[i], i), - }; - - final ops = []; - for (final entry in localById.entries) { - final localEnvelope = entry.value.$1; - final localIndex = entry.value.$2; - final remote = remoteById[entry.key]; - final payload = jsonEncode(localEnvelope.payload); - - if (remote == null || remote.deleted) { - ops.add( - StrategyOp( - opId: const Uuid().v4(), - kind: StrategyOpKind.add, - entityType: StrategyOpEntityType.element, - entityPublicId: localEnvelope.publicId, - pagePublicId: pageId, - payload: payload, - sortIndex: localIndex, - ), - ); - continue; - } - - if (remote.payload != payload || - remote.sortIndex != localIndex || - remote.elementType != localEnvelope.elementType) { - ops.add( - StrategyOp( - opId: const Uuid().v4(), - kind: StrategyOpKind.patch, - entityType: StrategyOpEntityType.element, - entityPublicId: localEnvelope.publicId, - pagePublicId: pageId, - payload: payload, - sortIndex: localIndex, - ), - ); - } + StrategySettings _parsePageSettings(String? settingsJson) { + if (settingsJson == null || settingsJson.isEmpty) { + return StrategySettings(); } - - for (final remote in remoteElements) { - if (remote.deleted || localById.containsKey(remote.publicId)) { - continue; - } - ops.add( - StrategyOp( - opId: const Uuid().v4(), - kind: StrategyOpKind.delete, - entityType: StrategyOpEntityType.element, - entityPublicId: remote.publicId, - pagePublicId: pageId, - ), - ); + try { + return ref.read(strategySettingsProvider.notifier).fromJson(settingsJson); + } catch (_) { + return StrategySettings(); } + } - final remoteLineups = _snapshot.lineupsByPage[pageId] ?? const []; - final remoteLineupsById = { - for (final lineup in remoteLineups) lineup.publicId: lineup, - }; - final localLineups = ref.read(lineUpProvider).lineUps; - final localLineupsById = { - for (var i = 0; i < localLineups.length; i++) localLineups[i].id: (localLineups[i], i), - }; - - for (final entry in localLineupsById.entries) { - final lineup = entry.value.$1; - final localIndex = entry.value.$2; - final payload = jsonEncode(lineup.toJson()); - final remote = remoteLineupsById[entry.key]; - - if (remote == null || remote.deleted) { - ops.add( - StrategyOp( - opId: const Uuid().v4(), - kind: StrategyOpKind.add, - entityType: StrategyOpEntityType.lineup, - entityPublicId: lineup.id, - pagePublicId: pageId, - payload: payload, - sortIndex: localIndex, - ), - ); - continue; - } - - if (remote.payload != payload || remote.sortIndex != localIndex) { - ops.add( - StrategyOp( - opId: const Uuid().v4(), - kind: StrategyOpKind.patch, - entityType: StrategyOpEntityType.lineup, - entityPublicId: lineup.id, - pagePublicId: pageId, - payload: payload, - sortIndex: localIndex, - ), - ); + Map _decodeJsonObject(String payload) { + try { + final decoded = jsonDecode(payload); + if (decoded is Map) { + return decoded; } - } - - for (final remote in remoteLineups) { - if (remote.deleted || localLineupsById.containsKey(remote.publicId)) { - continue; + if (decoded is Map) { + return Map.from(decoded); } - ops.add( - StrategyOp( - opId: const Uuid().v4(), - kind: StrategyOpKind.delete, - entityType: StrategyOpEntityType.lineup, - entityPublicId: remote.publicId, - pagePublicId: pageId, - ), - ); - } - - final page = _snapshot.pages.firstWhere( - (entry) => entry.publicId == pageId, - orElse: () => _snapshot.pages.first, - ); - final latestSettings = ref.read(strategySettingsProvider.notifier).toJson(); - if (page.settings != latestSettings || page.isAttack != ref.read(mapProvider).isAttack) { - ops.add( - StrategyOp( - opId: const Uuid().v4(), - kind: StrategyOpKind.patch, - entityType: StrategyOpEntityType.page, - entityPublicId: page.publicId, - payload: jsonEncode( - { - 'settings': latestSettings, - 'isAttack': ref.read(mapProvider).isAttack, - }, - ), - ), - ); + } catch (_) { + // Ignore malformed payloads during hydration. } - - return ops; + return {}; } -} - -class _CollabElementEnvelope { - const _CollabElementEnvelope({ - required this.publicId, - required this.elementType, - required this.payload, - }); - final String publicId; - final String elementType; - final Map payload; } diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index 7088aad3..35e8d819 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -359,7 +359,8 @@ class _ExpandedPanel extends StatelessWidget { padding: const EdgeInsets.only(top: _topPadding), child: ReorderableListView.builder( onReorder: onReorder == null ? (_, __) {} : onReorder!, - buildDefaultDragHandles: canReorderPages, + // Rows use ReorderableDragStartListener; default handles overlap delete. + buildDefaultDragHandles: false, padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), shrinkWrap: needsScroll ? false : true, physics: diff --git a/macos/Podfile.lock b/macos/Podfile.lock index fa444b3a..84fad05d 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,8 +1,16 @@ PODS: + - app_links (6.4.1): + - FlutterMacOS + - convex_flutter (0.0.1): + - FlutterMacOS + - cryptography_flutter_plus (0.2.0): + - FlutterMacOS - custom_mouse_cursor (0.0.1): - FlutterMacOS - desktop_drop (0.0.1): - FlutterMacOS + - desktop_updater (0.0.1): + - FlutterMacOS - file_picker (0.0.1): - FlutterMacOS - flutter_inappwebview_macos (0.0.1): @@ -17,20 +25,28 @@ PODS: - FlutterMacOS - screen_retriever_macos (0.0.1): - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS - window_manager (0.2.0): - FlutterMacOS DEPENDENCIES: + - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - convex_flutter (from `Flutter/ephemeral/.symlinks/plugins/convex_flutter/macos`) + - cryptography_flutter_plus (from `Flutter/ephemeral/.symlinks/plugins/cryptography_flutter_plus/macos`) - custom_mouse_cursor (from `Flutter/ephemeral/.symlinks/plugins/custom_mouse_cursor/macos`) - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) + - desktop_updater (from `Flutter/ephemeral/.symlinks/plugins/desktop_updater/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - pasteboard (from `Flutter/ephemeral/.symlinks/plugins/pasteboard/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) @@ -39,10 +55,18 @@ SPEC REPOS: - OrderedSet EXTERNAL SOURCES: + app_links: + :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + convex_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/convex_flutter/macos + cryptography_flutter_plus: + :path: Flutter/ephemeral/.symlinks/plugins/cryptography_flutter_plus/macos custom_mouse_cursor: :path: Flutter/ephemeral/.symlinks/plugins/custom_mouse_cursor/macos desktop_drop: :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos + desktop_updater: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_updater/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos flutter_inappwebview_macos: @@ -55,14 +79,20 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin screen_retriever_macos: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos window_manager: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: + app_links: c3185399a5cabc2e610ee5ad52fb7269b84ff869 + convex_flutter: 8cfa610fc48ddd56ec7a40fee812f6ac843de187 + cryptography_flutter_plus: b790cf76f050be15566d4ae320574b35cff72d1e custom_mouse_cursor: 19b9cb1bcd2e792e326c15e32b48f8dba06132da desktop_drop: e52397f93b3daec9fe1d504f1d5a21b76403d8ae + desktop_updater: c6cadea9a19511e0b0630183a58e949ad8f99031 file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 @@ -70,6 +100,7 @@ SPEC CHECKSUMS: pasteboard: 9b69dba6fedbb04866be632205d532fe2f6b1d99 path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 diff --git a/pubspec.lock b/pubspec.lock index 8d762ad5..93be18dd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -189,10 +189,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -833,18 +833,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1318,10 +1318,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.7" theme_extensions_builder_annotation: dependency: transitive description: diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 52c984bb..3a933805 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -12,6 +12,8 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; @@ -61,6 +63,7 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { final String? strategyPublicId; int enqueueAllCount = 0; + int syncDesiredOpsForPageCount = 0; int flushNowCount = 0; final List enqueuedOps = []; @@ -76,8 +79,10 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { void setActiveStrategy(String? strategyPublicId) { state = state.copyWith( strategyPublicId: strategyPublicId, - pending: const [], + queuedByEntityKey: const {}, + inFlightByEntityKey: const {}, lastAcks: const [], + lastAckBatch: const [], clearError: true, ); } @@ -87,11 +92,20 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { final collected = ops.toList(growable: false); enqueueAllCount++; enqueuedOps.addAll(collected); + final queued = {}; + for (final op in collected) { + final key = entityKeyForStrategyOp(op); + if (key == null) { + continue; + } + queued[key] = QueuedEntityIntent( + entityKey: key, + pending: PendingOp(op: op, clientId: state.clientId ?? 'test-client'), + ); + } state = state.copyWith( - pending: [ - for (final op in collected) - PendingOp(op: op, clientId: state.clientId ?? 'test-client'), - ], + queuedByEntityKey: queued, + inFlightByEntityKey: const {}, clearError: true, ); if (flushImmediately) { @@ -99,18 +113,38 @@ class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { } } + @override + void syncDesiredOpsForPage({ + required String pageId, + required Map desiredOpsByEntityKey, + bool clearMissing = true, + bool flushImmediately = false, + }) { + syncDesiredOpsForPageCount++; + super.syncDesiredOpsForPage( + pageId: pageId, + desiredOpsByEntityKey: desiredOpsByEntityKey, + clearMissing: clearMissing, + flushImmediately: flushImmediately, + ); + } + @override Future flushNow() async { flushNowCount++; state = state.copyWith( - pending: const [], + queuedByEntityKey: const {}, + inFlightByEntityKey: const {}, isFlushing: false, lastFlushAt: DateTime.now(), ); } - void emitAcks(List acks) { - state = state.copyWith(lastAcks: acks); + void emitAcks(List acks, [List? ackBatch]) { + state = state.copyWith( + lastAcks: acks, + lastAckBatch: ackBatch ?? const [], + ); } } @@ -173,18 +207,21 @@ RemoteElement _remoteText({ required String pageId, required String elementId, required String text, + int sortIndex = 0, }) { final placedText = PlacedText( id: elementId, position: const Offset(10, 20), )..text = text; + final payload = Map.from(placedText.toJson()) + ..putIfAbsent('elementType', () => 'text'); return RemoteElement( publicId: elementId, strategyPublicId: strategyId, pagePublicId: pageId, elementType: 'text', - payload: jsonEncode(placedText.toJson()), - sortIndex: 0, + payload: jsonEncode(payload), + sortIndex: sortIndex, revision: 1, deleted: false, ); @@ -338,7 +375,94 @@ void main() { expect(container.read(textProvider).single.text, 'after'); }); - test('reject refresh does not flush current cloud page', () async { + test('projected active-page merge prefers local overlay for touched entities', + () async { + const strategyId = 'cloud-strategy'; + final pageOne = + _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); + final updatedSnapshot = _cloudSnapshot( + strategyId: strategyId, + sequence: 2, + pages: [pageOne], + elementsByPage: { + 'page-1': [ + _remoteText( + strategyId: strategyId, + pageId: 'page-1', + elementId: 'text-1', + text: 'remote-a', + sortIndex: 0, + ), + _remoteText( + strategyId: strategyId, + pageId: 'page-1', + elementId: 'text-2', + text: 'remote-b-updated', + sortIndex: 1, + ), + ], + }, + ); + + final remoteNotifier = _FakeRemoteStrategySnapshotNotifier(updatedSnapshot); + final queueNotifier = _FakeStrategyOpQueueNotifier(strategyId); + final container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith( + () => _StaticStrategyProvider( + const StrategyState( + strategyId: strategyId, + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ), + ), + ), + remoteStrategySnapshotProvider.overrideWith(() => remoteNotifier), + strategyOpQueueProvider.overrideWith(() => queueNotifier), + ], + ); + addTearDown(container.dispose); + await container.read(remoteStrategySnapshotProvider.future); + + final localTextPayload = Map.from( + (PlacedText(id: 'text-1', position: const Offset(10, 20))..text = 'local-a') + .toJson(), + )..putIfAbsent('elementType', () => 'text'); + container.read(activePageLiveSyncProvider.notifier).setStateForTest( + ActivePageLiveSyncState( + strategyPublicId: strategyId, + activePageId: 'page-1', + overlayByEntityKey: { + elementEntityKey('page-1', 'text-1'): ActivePageOverlayEntry( + entityKey: elementEntityKey('page-1', 'text-1'), + entityType: ActivePageOverlayEntityType.element, + desiredPayload: jsonEncode(localTextPayload), + desiredSortIndex: 0, + deletion: false, + baseRevision: 1, + dirtyAt: DateTime.now(), + ), + }, + ), + ); + + final projectedState = container + .read(activePageLiveSyncProvider.notifier) + .projectPageState(strategyPublicId: strategyId, pageId: 'page-1'); + + final textsById = { + for (final element in projectedState!.elements) + element.publicId: PlacedText.fromJson( + jsonDecode(element.payload) as Map, + ).text, + }; + expect(textsById['text-1'], 'local-a'); + expect(textsById['text-2'], 'remote-b-updated'); + }); + + test('reject refresh preserves local state and queues follow-up sync', () async { const strategyId = 'cloud-strategy'; final pageOne = _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); @@ -399,17 +523,37 @@ void main() { remoteNotifier.setSnapshot(updatedSnapshot); queueNotifier.emitAcks(const [ OpAck( + opId: 'op-1', + status: 'reject', + latestSequence: 2, + reason: 'conflict', + ), + ], const [ + AckedEntityIntent( + entityKey: 'element:page-1:text-1', + op: StrategyOp( + opId: 'op-1', + kind: StrategyOpKind.patch, + entityType: StrategyOpEntityType.element, + entityPublicId: 'text-1', + pagePublicId: 'page-1', + payload: '{"text":"after"}', + ), + ack: OpAck( opId: 'op-1', status: 'reject', latestSequence: 2, - reason: 'conflict'), + reason: 'conflict', + ), + ), ]); await _settle(); expect(remoteNotifier.refreshCount, 1); - expect(queueNotifier.enqueueAllCount, 0); + expect(queueNotifier.syncDesiredOpsForPageCount, 1); expect(queueNotifier.flushNowCount, 0); - expect(container.read(textProvider).single.text, 'after'); + expect(container.read(textProvider).single.text, 'local-only'); + expect(container.read(strategyOpQueueProvider).pending, isNotEmpty); }); test('user page switch still flushes current cloud page', () async { @@ -463,8 +607,7 @@ void main() { .read(strategyPageSessionProvider.notifier) .setActivePage('page-2'); - expect(queueNotifier.enqueueAllCount, 1); - expect(queueNotifier.enqueuedOps, isNotEmpty); + expect(queueNotifier.syncDesiredOpsForPageCount, 1); expect(queueNotifier.flushNowCount, 1); expect(container.read(textProvider).single.text, 'page-two'); }); @@ -604,6 +747,7 @@ void main() { queueNotifier ..enqueueAllCount = 0 + ..syncDesiredOpsForPageCount = 0 ..flushNowCount = 0 ..enqueuedOps.clear(); container.read(textProvider.notifier).fromHive([ @@ -627,12 +771,13 @@ void main() { overlay_transition.PageTransitionPhase.preparing, ); expect(transitionState.direction, PageTransitionDirection.backward); + expect(queueNotifier.syncDesiredOpsForPageCount, 1); expect(queueNotifier.flushNowCount, 1); expect(container.read(strategyPageSessionProvider).activePageId, 'page-1'); expect(container.read(textProvider).single.text, 'before'); }); - test('pending remote reapply resumes through non-flushing path', () async { + test('pending cloud sync does not block projected active-page rehydrate', () async { const strategyId = 'cloud-strategy'; final pageOne = _remotePage(strategyId: strategyId, pageId: 'page-1', sortIndex: 0); @@ -693,13 +838,6 @@ void main() { remoteNotifier.setSnapshot(updatedSnapshot); await _settle(); - expect(container.read(textProvider).single.text, 'before'); - expect(queueNotifier.enqueueAllCount, 0); - expect(queueNotifier.flushNowCount, 0); - - container.read(strategySaveStateProvider.notifier).markPersisted(); - await _settle(); - expect(container.read(textProvider).single.text, 'after'); expect(queueNotifier.enqueueAllCount, 0); expect(queueNotifier.flushNowCount, 0);