Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cc485e0
Merge remote-tracking branch 'origin/t3/lineup-page-move' into t3/clo…
SunkenInTime Oct 10, 2026
b286bbb
Copy the page on screen when "+" is pressed on a cloud strategy
SunkenInTime Oct 10, 2026
0cc36a7
Finish a cloud page copy when the page lands late; stop it on a switch
SunkenInTime Oct 10, 2026
40226ce
Only watch, and stop on dispose, while a cloud page lands late
SunkenInTime Oct 10, 2026
279dae6
Add the cloud page directly before copying onto it
SunkenInTime Oct 10, 2026
914cf14
Take back the client-side page copy; the server will copy
SunkenInTime Oct 10, 2026
c6741c8
Merge remote-tracking branch 'origin/t3/duplicate-keeps-roots' into t…
SunkenInTime Oct 10, 2026
d087a41
Merge remote-tracking branch 'origin/t3/page-add-copies-content' into…
SunkenInTime Oct 10, 2026
374bdfd
Ask the server to copy the page when "+" adds one in the cloud
SunkenInTime Oct 10, 2026
cb48e38
Follow "+" to the server's answer, and say what the copy lacks
SunkenInTime Oct 10, 2026
e2db829
Merge remote-tracking branch 'origin/t3/page-add-copies-content' into…
SunkenInTime Oct 10, 2026
95788f4
Don't wait on a server that never answers in the page add test
SunkenInTime Oct 10, 2026
70b1b83
Say what a page copy may lack when "+" is pressed
SunkenInTime Oct 10, 2026
72ace07
Merge remote-tracking branch 'origin/t3/page-add-copies-content' into…
SunkenInTime Oct 10, 2026
7372caf
Name source edits refused in the same send as the page add
SunkenInTime Oct 10, 2026
251324f
Merge remote-tracking branch 'origin/t3/cloud-image-copy' into t3/clo…
SunkenInTime Oct 10, 2026
7430a48
Merge remote-tracking branch 'origin/t3/page-add-copies-content' into…
SunkenInTime Oct 10, 2026
e6c3d16
Drop the "still uploading" warning from "+"
SunkenInTime Oct 10, 2026
33320d9
Merge remote-tracking branch 'origin/t3/cloud-image-copy' into t3/clo…
SunkenInTime Oct 10, 2026
e3f56bb
Merge remote-tracking branch 'origin/t3/page-add-copies-content' into…
SunkenInTime Oct 10, 2026
0b849ae
Merge remote-tracking branch 'origin/t3/cloud-image-copy' into t3/clo…
SunkenInTime Oct 10, 2026
b251608
Merge remote-tracking branch 'origin/t3/page-add-copies-content' into…
SunkenInTime Oct 10, 2026
ba2c257
Merge remote-tracking branch 'origin/main' into t3/cloud-page-duplicate
SunkenInTime Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion lib/collab/cloud_sync_error_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ bool isSpecificAttentionReason(String error) {
lower.contains(lineupOverlapMessage.toLowerCase()) ||
lower.contains(retiredLineupOpMessage.toLowerCase()) ||
lower.contains(teammateDeletedMessage.toLowerCase()) ||
lower.contains(pageDeletedMessage.toLowerCase());
lower.contains(pageDeletedMessage.toLowerCase()) ||
lower.contains(pageTooLargeToCopyMessage.toLowerCase());
}

/// A cloud change whose write to the durable outbox failed, or could not be
Expand Down Expand Up @@ -125,6 +126,10 @@ String friendlyCloudSyncError(String raw) {
return 'A saved cloud change is paused after repeated failures. Retry '
'when the connection and account are healthy.';
}
if (lower.contains(pageTooLargeToCopyMessage.toLowerCase())) {
return 'A new page was too large for the cloud to copy, so it was not '
'added. Keep mine tries again; Use cloud drops it.';
}
if (lower.contains('too large for cloud sync')) {
return 'A saved change is too large for cloud sync. It remains saved on '
'this device. Reduce it, then choose Keep mine to retry, or Use '
Expand Down
18 changes: 17 additions & 1 deletion lib/collab/collab_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ const teammateDeletedCannotRestoreMessage =
/// sent again.
const pageDeletedMessage = 'This page was deleted';

/// The server's refusal of a page add whose copy of another page
/// (PageAddOp.copyContentFromPagePublicId) is too large to make in one go
/// (PAGE_TOO_LARGE_TO_COPY, convex/ops.ts).
const pageTooLargeToCopyMessage = 'This page is too large to copy.';

/// How long the server keeps a deleted page restorable (the server's
/// PAGE_TRASH_RETENTION_MS), for copy that promises it.
const pageTrashRetentionDays = 30;
Expand Down Expand Up @@ -323,13 +328,15 @@ sealed class StrategyOp {
'payload': payload,
'expectedStrategyRevision': expectedRevision,
},
PageAddOp() => {
PageAddOp(:final copyContentFromPagePublicId) => {
'opId': opId,
'type': type.wireName,
'pagePublicId': pagePublicId,
'payload': payload,
'sortIndex': sortIndex,
'expectedStrategyRevision': expectedRevision,
if (copyContentFromPagePublicId != null)
'copyContentFromPagePublicId': copyContentFromPagePublicId,
},
PagePatchOp() => {
'opId': opId,
Expand Down Expand Up @@ -448,6 +455,8 @@ sealed class StrategyOp {
sortIndex: _requiredInt(json['sortIndex']),
expectedStrategyRevision:
_requiredInt(json['expectedStrategyRevision']),
copyContentFromPagePublicId:
json['copyContentFromPagePublicId'] as String?,
),
StrategyOpType.pagePatch => PagePatchOp(
opId: opId,
Expand Down Expand Up @@ -633,13 +642,15 @@ sealed class StrategyOp {
:final payload,
:final sortIndex,
:final expectedStrategyRevision,
:final copyContentFromPagePublicId,
) =>
PageAddOp(
opId: value,
pagePublicId: pagePublicId,
payload: payload,
sortIndex: sortIndex,
expectedStrategyRevision: expectedStrategyRevision,
copyContentFromPagePublicId: copyContentFromPagePublicId,
),
PagePatchOp(
:final pagePublicId,
Expand Down Expand Up @@ -821,6 +832,7 @@ final class PageAddOp extends StrategyOp {
required this.payload,
required this.sortIndex,
required this.expectedStrategyRevision,
this.copyContentFromPagePublicId,
});
@override
final String opId;
Expand All @@ -831,6 +843,10 @@ final class PageAddOp extends StrategyOp {
@override
final int sortIndex;
final int expectedStrategyRevision;

/// The page whose items and lineups the server copies onto this one as
/// it adds it ("+"), under copy ids (see page_copy_id.dart).
final String? copyContentFromPagePublicId;
@override
StrategyOpType get type => StrategyOpType.pageAdd;
}
Expand Down
17 changes: 14 additions & 3 deletions lib/page_transition/transition_planner.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

import 'package:icarus/const/drawing_element.dart';
import 'package:icarus/const/page_copy_id.dart';
import 'package:icarus/const/placed_classes.dart';
Expand Down Expand Up @@ -121,14 +123,23 @@ class TransitionPlanner {
/// Whether the drawing layer changes between two pages, and therefore
/// whether it should fade in early during the transition. Compares the
/// serialized form so geometry/style edits count, not just added or
/// removed strokes.
/// removed strokes. A copied stroke counts as its original (see
/// page_copy_id.dart): a cloud page copy gives each its own id.
static bool drawingsChanged(
List<DrawingElement> prev,
List<DrawingElement> next,
) {
if (identical(prev, next)) return false;
if (prev.length != next.length) return true;
return DrawingProvider.objectToJson(prev) !=
DrawingProvider.objectToJson(next);
return _drawingsByRoot(prev) != _drawingsByRoot(next);
}

static String _drawingsByRoot(List<DrawingElement> drawings) => jsonEncode([
for (final drawing
in jsonDecode(DrawingProvider.objectToJson(drawings)) as List)
{
...drawing as Map,
if (drawing['id'] case final String id) 'id': pageCopyRoot(id),
},
]);
}
9 changes: 8 additions & 1 deletion lib/providers/collab/strategy_op_queue_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2676,6 +2676,7 @@ class StrategyOpQueueNotifier extends Notifier<StrategyOpQueueState> {
payload: {...existing.payload, ...desired.payload},
sortIndex: existing.sortIndex,
expectedStrategyRevision: existing.expectedStrategyRevision,
copyContentFromPagePublicId: existing.copyContentFromPagePublicId,
);
}
}
Expand Down Expand Up @@ -2937,13 +2938,19 @@ class StrategyOpQueueNotifier extends Notifier<StrategyOpQueueState> {
payload: payload,
expectedStrategyRevision: revision,
),
PageAddOp(:final pagePublicId, :final payload, :final sortIndex) =>
PageAddOp(
:final pagePublicId,
:final payload,
:final sortIndex,
:final copyContentFromPagePublicId,
) =>
PageAddOp(
opId: opId,
pagePublicId: pagePublicId,
payload: payload,
sortIndex: sortIndex,
expectedStrategyRevision: revision,
copyContentFromPagePublicId: copyContentFromPagePublicId,
),
PagePatchOp(:final pagePublicId, :final payload) => PagePatchOp(
opId: opId,
Expand Down
129 changes: 103 additions & 26 deletions lib/providers/strategy_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ enum PageCopyResult {
unavailable,
}

/// What a new cloud page may lack, and whether it has reached the cloud
/// yet (see StrategyProvider.addPage): edits to the page it copies that
/// the server refused, which its copy never had.
typedef NewPageGaps = ({bool unsavedEdits, bool waitingForCloud});

class StrategyProvider extends Notifier<StrategyState> {
@override
StrategyState build() {
Expand Down Expand Up @@ -399,6 +404,34 @@ class StrategyProvider extends Notifier<StrategyState> {
..setCloudSyncError(null);
}

/// Queues [op], sends it, and returns the server's answer if it comes
/// within [wait] of the call, sending included. None when it doesn't
/// (offline, or behind other work): the op stays queued and lands later.
Future<OpAck?> _sendAndAwaitAnswer(StrategyOp op, Duration wait) async {
final answered = Completer<OpAck?>();
void answer(OpAck? ack) {
if (!answered.isCompleted) answered.complete(ack);
}

final subscription = ref.listen(strategyOpQueueProvider, (_, next) {
final ack = next.lastAcks.where((ack) => ack.opId == op.opId).firstOrNull;
if (ack != null) answer(ack);
});
final deadline = Timer(wait, () => answer(null));
unawaited(_enqueueCloudPageDescriptorOp(op).then(
(ack) {
if (ack != null) answer(ack);
},
onError: (Object error) => log('Could not send ${op.opId}: $error'),
));
try {
return await answered.future;
} finally {
deadline.cancel();
subscription.close();
}
}

Future<OpAck?> _enqueueCloudPageDescriptorOp(StrategyOp op) async {
await enqueueOps([op]);
final queue = ref.read(strategyOpQueueProvider.notifier);
Expand Down Expand Up @@ -1475,11 +1508,22 @@ class StrategyProvider extends Notifier<StrategyState> {
return null;
}

Future<void> addPage([String? name]) async {
if (!_currentStrategyCanEditPages()) return;
/// How long "+" waits for the server to add a cloud page before saying
/// it will appear once it gets there.
@visibleForTesting
static Duration cloudPageAddWait = const Duration(seconds: 5);

/// Adds a copy of the page on screen after it, and turns to it. In the
/// cloud the server makes the copy (convex/lib/contentCopy.ts); what the
/// copy may lack, or that it hasn't landed yet, is returned for the
/// caller to say.
Future<NewPageGaps> addPage([String? name]) async {
const none = (unsavedEdits: false, waitingForCloud: false);
if (!_currentStrategyCanEditPages()) return none;
if (_currentStrategyIsCloud()) {
final strategyId = state.strategyId;
final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull;
if (snapshot == null) return;
if (strategyId == null || snapshot == null) return none;
final pages = [...snapshot.pages]
..sortBySortIndex((item) => item.sortIndex);
final pageID = const Uuid().v4();
Expand All @@ -1490,28 +1534,60 @@ class StrategyProvider extends Notifier<StrategyState> {
final sourceIndex = activeIndex >= 0 ? activeIndex : pages.length - 1;
final nextIndex = sourceIndex + 1;
final isAutoNamed = name == null;
final ack = await _enqueueCloudPageDescriptorOp(PageAddOp(
opId: const Uuid().v4(),
pagePublicId: pageID,
payload: {
'name': name ?? 'Page ${nextIndex + 1}',
'isAutoNamed': isAutoNamed,
'isAttack': pages.isNotEmpty ? pages[sourceIndex].isAttack : true,
'settings': ref.read(strategySettingsProvider).toJson(),
},
sortIndex: nextIndex,
expectedStrategyRevision: snapshot.header.revision,
));
if (ack?.isAck ?? false) {
await ref.read(remoteEditorSnapshotProvider.notifier).refresh();
await ref
.read(strategyPageSessionProvider.notifier)
.setActivePageAnimated(
pageID,
direction: PageTransitionDirection.forward,
);
final sourcePageId = pages.isEmpty ? null : pages[sourceIndex].publicId;
// Edits made just before "+" go ahead of the add, so the server's
// copy has them.
await ref.read(strategyPageSessionProvider.notifier).flushCurrentPage();
if (state.strategyId != strategyId) return none;
// Edits to the page the server refused: the copy, made from the
// server's page, doesn't have them.
bool sourceEditsRefused() =>
activeIndex >= 0 &&
ref
.read(strategyOpQueueProvider)
.attentionByEntityKey
.keys
.any((key) => key.pageId == sourcePageId);
final refusedBefore = sourceEditsRefused();
final answer = await _sendAndAwaitAnswer(
PageAddOp(
opId: const Uuid().v4(),
pagePublicId: pageID,
payload: {
'name': name ?? 'Page ${nextIndex + 1}',
'isAutoNamed': isAutoNamed,
'isAttack': pages.isNotEmpty ? pages[sourceIndex].isAttack : true,
'settings': ref.read(strategySettingsProvider).toJson(),
},
sortIndex: nextIndex,
expectedStrategyRevision: snapshot.header.revision,
copyContentFromPagePublicId: sourcePageId,
),
cloudPageAddWait,
);
if (answer == null) {
// Offline, or behind other work: the page lands later.
return (unsavedEdits: refusedBefore, waitingForCloud: true);
}
return;
// A refusal waits in the sync panel like any other.
if (!answer.isAck || state.strategyId != strategyId) {
return (unsavedEdits: refusedBefore, waitingForCloud: false);
}
// Edits sent ahead of the add have their answers too by now, and the
// server may have refused some of them.
final landed = (
unsavedEdits: sourceEditsRefused(),
waitingForCloud: false,
);
await ref.read(remoteEditorSnapshotProvider.notifier).refresh();
if (state.strategyId != strategyId) return landed;
await ref
.read(strategyPageSessionProvider.notifier)
.setActivePageAnimated(
pageID,
direction: PageTransitionDirection.forward,
);
return landed;
}

final box = Hive.box<StrategyData>(HiveBoxNames.strategiesBox);
Expand All @@ -1520,9 +1596,9 @@ class StrategyProvider extends Notifier<StrategyState> {
await _syncCurrentPageToHive();

final strategyId = state.strategyId;
if (strategyId == null) return;
if (strategyId == null) return none;
final strat = box.get(strategyId);
if (strat == null || strat.pages.isEmpty) return;
if (strat == null || strat.pages.isEmpty) return none;

final orderedPages = [...strat.pages]
..sortBySortIndex((item) => item.sortIndex);
Expand Down Expand Up @@ -1552,6 +1628,7 @@ class StrategyProvider extends Notifier<StrategyState> {
await box.put(updated.id, updated);

await setActivePageAnimated(newPage.id);
return none;
}

Future<void> renamePage(String pageId, String newName) async {
Expand Down
5 changes: 4 additions & 1 deletion lib/widgets/global_shortcuts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import 'package:icarus/widgets/rotate_helpers.dart';
import 'package:icarus/config/platform_policy.dart';
import 'package:icarus/widgets/platform_feature_toast.dart';
import 'package:uuid/uuid.dart';
import 'package:icarus/widgets/new_page_copy_toast.dart';

class GlobalShortcuts extends ConsumerStatefulWidget {
const GlobalShortcuts({super.key, required this.child});
Expand Down Expand Up @@ -143,7 +144,9 @@ class _GlobalShortcutsState extends ConsumerState<GlobalShortcuts>
onInvoke: (intent) async {
if (!capabilities.canAddPage) return null;
_dismissDeleteMenu();
await ref.read(strategyProvider.notifier).addPage();
showNewPageGaps(
await ref.read(strategyProvider.notifier).addPage(),
);
return null;
},
),
Expand Down
21 changes: 21 additions & 0 deletions lib/widgets/new_page_copy_toast.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'package:icarus/const/settings.dart';
import 'package:icarus/providers/strategy_provider.dart';

/// Says what a new page's copy lacks, and when the page hasn't reached
/// the cloud yet, if either (see StrategyProvider.addPage).
void showNewPageGaps(NewPageGaps gaps) {
if (gaps.waitingForCloud) {
Settings.showToast(
message: "The new page hasn't reached the cloud yet. It will appear "
'once it does.',
backgroundColor: Settings.tacticalVioletTheme.primary,
);
}
if (gaps.unsavedEdits) {
Settings.showToast(
message: "Changes to the page you copied that didn't save won't be in "
'the copy.',
backgroundColor: Settings.tacticalVioletTheme.destructive,
);
}
}
5 changes: 4 additions & 1 deletion lib/widgets/pages_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'package:icarus/widgets/dialogs/delete_page_dialog.dart';
import 'package:icarus/widgets/dialogs/recently_deleted_dialog.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:toastification/toastification.dart';
import 'package:icarus/widgets/new_page_copy_toast.dart';

const double _pagesBarCornerRadius = 12;
const double _pagesBarFooterHeight = 48;
Expand Down Expand Up @@ -216,7 +217,9 @@ class _PagesBarState extends ConsumerState<PagesBar> {
Future<void> _addPage() async {
final caps = ref.read(currentStrategyCapabilitiesProvider);
if (!caps.canAddPage) return;
await ref.read(strategyProvider.notifier).addPage();
showNewPageGaps(
await ref.read(strategyProvider.notifier).addPage(),
);
}

Future<void> _selectPage(String id) async {
Expand Down
Loading
Loading