Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
646ef7e
Let a cloud image be copied under a new id on the server
SunkenInTime Oct 10, 2026
0699b75
Merge remote-tracking branch 'origin/t3/copy-image-asset' into t3/clo…
SunkenInTime Oct 10, 2026
8f39a67
Copy placed images to the next or previous cloud page
SunkenInTime Oct 10, 2026
a692d7c
Replace a failed upload with a copy; reclaim copies nothing uses
SunkenInTime Oct 10, 2026
db60860
Merge remote-tracking branch 'origin/t3/copy-image-asset' into t3/clo…
SunkenInTime Oct 10, 2026
f49e0c3
Keep a copied image's row until something that showed it goes
SunkenInTime Oct 10, 2026
aa9cc56
Merge remote-tracking branch 'origin/t3/copy-image-asset' into t3/clo…
SunkenInTime Oct 10, 2026
bb2001c
Call an image this device is still uploading uploading, not missing
SunkenInTime Oct 10, 2026
f3922cb
Refuse a copy onto an id that already shows another image
SunkenInTime Oct 10, 2026
8454ab7
Merge remote-tracking branch 'origin/t3/copy-image-asset' into t3/clo…
SunkenInTime Oct 10, 2026
610133f
Don't call an image uploading when its upload will never go
SunkenInTime Oct 10, 2026
26658e6
Find an image's picture by its picture id, not its item id
SunkenInTime Oct 10, 2026
cd490fb
Take back the server-side picture copy
SunkenInTime Oct 10, 2026
2bf7e8d
Merge branch 't3/image-picture-id-client' into t3/cloud-image-copy-re…
SunkenInTime Oct 10, 2026
69bd2b4
Copy an image to another cloud page by its picture, not a copy of it
SunkenInTime Oct 10, 2026
efc15fd
Merge branch 't3/image-picture-id' into t3/image-picture-id-client
SunkenInTime Oct 10, 2026
9f4b327
Ask the server for images' picture ids
SunkenInTime Oct 10, 2026
0c902af
Merge remote-tracking branch 'origin/t3/image-picture-id-client' into…
SunkenInTime Oct 10, 2026
5b2394d
Merge remote-tracking branch 'origin/t3/image-picture-id' into t3/ima…
SunkenInTime Oct 10, 2026
e158aa9
Merge remote-tracking branch 'origin/t3/image-picture-id-client' into…
SunkenInTime Oct 10, 2026
7e73f52
Merge remote-tracking branch 'origin/main' into t3/cloud-image-copy-r…
SunkenInTime Oct 10, 2026
55756bb
Merge remote-tracking branch 'origin/main' into t3/cloud-image-copy-r…
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
2 changes: 1 addition & 1 deletion lib/collab/cloud_media_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ Set<String> collectStrategyImageAssetIds(StrategyDataLike strategy) {
final assetIds = <String>{};
for (final page in strategy.pages) {
for (final image in page.imageData) {
assetIds.add(image.id);
assetIds.add(image.pictureId);
}
for (final link in page.lineUpLinks) {
for (final image in link.images) {
Expand Down
6 changes: 6 additions & 0 deletions lib/collab/convex_strategy_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ class ConvexStrategyRepository {
strategyPublicId: strategyPublicId,
pagePublicId: pagePublicId,
shareToken: _optional(shareToken),
acceptsPictureIds: const ConvexOptional.present(true),
)
.fetch(),
);
Expand All @@ -179,6 +180,8 @@ class ConvexStrategyRepository {
strategyPublicId: strategyPublicId,
pagePublicId: pagePublicId,
shareToken: _optional(shareToken),
// This client keeps an image's picture id (PlacedImage.assetId).
acceptsPictureIds: const ConvexOptional.present(true),
)
.watch()
.map(_pageSnapshot);
Expand Down Expand Up @@ -226,6 +229,7 @@ class ConvexStrategyRepository {
// This client checks image references apart, so it can take a
// snapshot without the pages in the server's trash.
acceptsTrashedPagesLeftOut: const ConvexOptional.present(true),
acceptsPictureIds: const ConvexOptional.present(true),
)
.fetch(),
));
Expand Down Expand Up @@ -351,6 +355,8 @@ class ConvexStrategyRepository {
accountSubject: _optional(accountSubject),
// This client restores deleted pages, so it sends such a delete again.
checkTrashedPageDeletes: const ConvexOptional.present(true),
// This client keeps an image's picture id (PlacedImage.assetId).
acceptsPictureIds: const ConvexOptional.present(true),
);
return result.results.map(_opAck).toList(growable: false);
}
Expand Down
13 changes: 13 additions & 0 deletions lib/const/placed_classes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,21 @@ class PlacedImage extends PlacedWidget {
this.sizeVersion,
this.tagColorValue,
this.link = '',
this.assetId,
});

final double aspectRatio;

/// The picture this image shows, when it isn't the image's own id: a
/// copy of an image is a new item showing its original's picture, with
/// nothing copied or uploaded. Absent on every image made before copies
/// shared pictures. Use [pictureId] to find the picture.
@JsonKey(includeIfNull: false)
final String? assetId;

/// The id the image's picture is stored, uploaded and found under.
String get pictureId => assetId ?? id;

final String? fileExtension;
double scale;

Expand Down Expand Up @@ -264,6 +275,7 @@ class PlacedImage extends PlacedWidget {
int? tagColorValue,
bool? isDeleted,
String? link,
String? assetId,
}) {
final cloned = PlacedImage(
position: position ?? this.position,
Expand All @@ -273,6 +285,7 @@ class PlacedImage extends PlacedWidget {
fileExtension: fileExtension ?? this.fileExtension,
sizeVersion: sizeVersion ?? this.sizeVersion,
tagColorValue: tagColorValue ?? this.tagColorValue,
assetId: assetId ?? this.assetId,
);
// Base class field
// cloned.isDeleted = isDeleted ?? this.isDeleted;
Expand Down
2 changes: 2 additions & 0 deletions lib/const/placed_classes.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions lib/hive/hive_adapters.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion lib/hive/hive_adapters.g.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ types:
index: 7
PlacedImage:
typeId: 5
nextIndex: 11
nextIndex: 12
fields:
aspectRatio:
index: 1
Expand All @@ -95,6 +95,8 @@ types:
index: 9
sizeVersion:
index: 10
assetId:
index: 11
MapValue:
typeId: 6
nextIndex: 13
Expand Down
25 changes: 18 additions & 7 deletions lib/providers/collab/cloud_media_upload_queue_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -449,29 +449,32 @@ class CloudMediaUploadQueueNotifier
assetPublicId: assetPublicId,
);

// By picture: a copy of an image shows its original's picture, which
// is uploaded once, for the original.
for (final image in placedImages) {
final asset = assetsById[image.id];
final pictureId = image.pictureId;
final asset = assetsById[pictureId];
final hasActiveRemote =
asset?.uploadStatus == 'active' && (asset?.url?.isNotEmpty ?? false);
if (hasActiveRemote || _getJob(image.id) != null) {
if (hasActiveRemote || _getJob(pictureId) != null) {
continue;
}

final bytes = await _findMediaBytes(
keyFor(image.id),
keyFor(pictureId),
fileExtension: image.fileExtension ?? '',
);
if (bytes == null) {
_logMedia(
'reconcile.local_missing image=${image.id} '
'reconcile.local_missing image=$pictureId '
'strategy=$strategyPublicId status=${asset?.uploadStatus ?? 'none'}',
);
continue;
}

await enqueueJobForLocalBytes(
strategyPublicId: strategyPublicId,
assetPublicId: image.id,
assetPublicId: pictureId,
fileExtension: image.fileExtension ?? '',
);
}
Expand Down Expand Up @@ -1192,10 +1195,10 @@ class CloudMediaUploadQueueNotifier

bool _opReferencesAsset(StrategyOp op, String assetPublicId) {
if (op is ElementAddOp) {
return op.elementPublicId == assetPublicId;
return _pictureOf(op.elementPublicId, op.payload) == assetPublicId;
}
if (op is ElementPatchOp) {
return op.elementPublicId == assetPublicId;
return _pictureOf(op.elementPublicId, op.payload) == assetPublicId;
}
if (op is LineupAddOp) {
return _jsonContainsAssetId(op.payload, assetPublicId);
Expand All @@ -1206,6 +1209,14 @@ class CloudMediaUploadQueueNotifier
return false;
}

/// The picture an element op shows when it is an image: its payload's
/// `assetId`, else the element's own id (see PlacedImage.pictureId).
static String _pictureOf(String elementPublicId, CloudPayload? payload) {
final assetId =
payload == null ? null : cloudPayloadData(payload)['assetId'];
return assetId is String && assetId.isNotEmpty ? assetId : elementPublicId;
}

bool _jsonContainsAssetId(Object? value, String assetPublicId) {
if (value is Map) {
if (value['id'] == assetPublicId) return true;
Expand Down
9 changes: 6 additions & 3 deletions lib/providers/image_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ class PlacedImageSerializer {
///
/// It uses the application support directory, creates a custom folder based
/// on [strategyID] and an `images` subfolder, and forms the filename from the
/// image's [id] and [fileExtension].
/// image's picture id ([PlacedImage.pictureId]) and [fileExtension].
static Future<String> _computeFilePath(
PlacedImage image, String strategyID) async {
// Get the system's application support directory.
Expand All @@ -699,8 +699,11 @@ class PlacedImageSerializer {
await imagesDirectory.create(recursive: true);
}

// The final file path: [id][fileExtension]
return path.join(imagesDirectory.path, '${image.id}${image.fileExtension}');
// The final file path: [pictureId][fileExtension]
return path.join(
imagesDirectory.path,
'${image.pictureId}${image.fileExtension}',
);
}

static String? detectImageFormat(Uint8List bytes) {
Expand Down
22 changes: 17 additions & 5 deletions lib/providers/strategy_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
import 'package:icarus/collab/canonical_json.dart';
import 'package:icarus/collab/cloud_media_models.dart';
import 'package:icarus/collab/collab_models.dart';
import 'package:icarus/collab/cloud_lineup_rows.dart';
import 'package:icarus/collab/strategy_capabilities.dart';
Expand Down Expand Up @@ -943,9 +944,7 @@ class StrategyProvider extends Notifier<StrategyState> {
}

/// [widgetId] on the page on screen as cloud element data, or null when it
/// cannot go to another cloud page. An image's id also names its file on
/// the server, so a copy of one needs its own file; images stay local-only
/// for now.
/// cannot go to another cloud page.
({String kind, Map<String, dynamic> data})? _cloudElementOnScreen(
String widgetId,
) {
Expand All @@ -972,6 +971,11 @@ class StrategyProvider extends Notifier<StrategyState> {
for (final utility in ref.read(utilityProvider)) {
if (utility.id == widgetId) return element('utility', utility.toJson());
}
for (final image in ref.read(placedImageProvider).images) {
if (image.id == widgetId) {
return element('image', cloudImagePayloadFromPlacedImage(image));
}
}
return null;
}

Expand Down Expand Up @@ -1060,7 +1064,15 @@ class StrategyProvider extends Notifier<StrategyState> {
pagePublicId: targetPageId,
payload: cloudElementPayload(
kind: element.kind,
data: {...element.data, 'id': copyId},
data: {
...element.data,
'id': copyId,
// A copied image shows its original's picture: nothing
// is copied or uploaded, and nothing waits on an upload
// still under way (see PlacedImage.assetId).
if (element.kind == 'image')
'assetId': element.data['assetId'] ?? widgetId,
},
),
sortIndex: 1 + onTarget.values.fold<int>(-1, max),
),
Expand Down Expand Up @@ -1682,7 +1694,7 @@ class StrategyProvider extends Notifier<StrategyState> {
if (!kIsWeb) {
List<String> allImageIds = [];
for (final page in newStrat.pages) {
allImageIds.addAll(page.imageData.map((image) => image.id));
allImageIds.addAll(page.imageData.map((image) => image.pictureId));
for (final link in page.lineUpLinks) {
allImageIds.addAll(link.images.map((image) => image.id));
}
Expand Down
5 changes: 3 additions & 2 deletions lib/screenshot/page_screenshot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ Future<Uint8List> captureEditorPage(WidgetRef ref) async {

final images = await resolveCaptureImages(
{
// By picture, as the captured page's images look them up.
for (final image in page.imageData)
image.id: readStrategyImageSource(
image.pictureId: readStrategyImageSource(
ref,
(id: image.id, fileExtension: image.fileExtension),
(id: image.pictureId, fileExtension: image.fileExtension),
),
},
fetch: (imageId, url, client) => downloadCloudImageBytes(
Expand Down
7 changes: 4 additions & 3 deletions lib/services/video_export/video_export_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,19 @@ Future<VideoExportSource> loadVideoExportSource(
final images = await resolveCaptureImages(
{
for (final page in pages)
// By picture, as the captured pages' images look them up.
for (final image in page.imageData)
image.id: resolveStrategyImageSource(
image.pictureId: resolveStrategyImageSource(
localFilePath: findLocalImageFile(
storageDirectory: state.storageDirectory,
imageId: image.id,
imageId: image.pictureId,
fileExtension: image.fileExtension,
),
isCloudStrategy: isCloud,
// The whole strategy was just read, and this device has
// nothing left to upload.
assetsLoaded: true,
remoteAsset: assets[image.id],
remoteAsset: assets[image.pictureId],
uploadMayBeQueuedHere: false,
),
},
Expand Down
6 changes: 5 additions & 1 deletion lib/strategy/strategy_cloud_migration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ void appendMigratedPageOps(

for (final image in page.imageData) {
final elementId = nextUniqueMigrationId(image.id, usedElementIds);
final payload = cloudImagePayloadFromPlacedImage(image)
// A renamed image keeps showing its picture, which is stored under its
// old id.
final payload = cloudImagePayloadFromPlacedImage(
elementId == image.id ? image : image.copyWith(assetId: image.pictureId),
)
..putIfAbsent('elementType', () => 'image')
..['id'] = elementId;
ops.add(
Expand Down
6 changes: 5 additions & 1 deletion lib/strategy/strategy_import_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2408,7 +2408,11 @@ class StrategyImportExportService {
if (element.deleted || element.elementType != 'image') {
continue;
}
assetIds.add(element.publicId);
// The picture it shows (PlacedImage.pictureId).
final assetId = cloudPayloadData(element.payload)['assetId'];
assetIds.add(
assetId is String && assetId.isNotEmpty ? assetId : element.publicId,
);
}

final lineups = lineUpGraphFromRemoteLineups(
Expand Down
4 changes: 2 additions & 2 deletions lib/strategy/strategy_page_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ class CloudStrategyPageSource implements StrategyPageSource {
break;
case 'image':
final hydrated = PlacedImage.fromJson(payload);
final remoteAsset = snapshot.assetsById[hydrated.id];
final remoteAsset = snapshot.assetsById[hydrated.pictureId];
images.add(hydrated);
if (remoteAsset != null) {
ref.read(cloudMediaCacheProvider.notifier).ensureAssetCached(
Expand Down Expand Up @@ -440,7 +440,7 @@ class CloudStrategyPageSource implements StrategyPageSource {
break;
case 'image':
final hydrated = PlacedImage.fromJson(payload);
final remoteAsset = snapshot.assetsById[hydrated.id];
final remoteAsset = snapshot.assetsById[hydrated.pictureId];
images.add(hydrated);
if (remoteAsset != null) {
ref.read(cloudMediaCacheProvider.notifier).ensureAssetCached(
Expand Down
Loading
Loading