hash)
@@ -86,7 +88,8 @@ class Asset {
this.isFavorite = false,
this.isArchived = false,
this.isTrashed = false,
- this.stackParentId,
+ this.stackId,
+ this.stackPrimaryAssetId,
this.stackCount = 0,
this.isOffline = false,
this.thumbhash,
@@ -163,12 +166,11 @@ class Asset {
@ignore
ExifInfo? exifInfo;
- String? stackParentId;
+ String? stackId;
- @ignore
- int get stackChildrenCount => stackCount ?? 0;
+ String? stackPrimaryAssetId;
- int? stackCount;
+ int stackCount;
/// Aspect ratio of the asset
@ignore
@@ -231,7 +233,8 @@ class Asset {
isArchived == other.isArchived &&
isTrashed == other.isTrashed &&
stackCount == other.stackCount &&
- stackParentId == other.stackParentId;
+ stackPrimaryAssetId == other.stackPrimaryAssetId &&
+ stackId == other.stackId;
}
@override
@@ -256,7 +259,8 @@ class Asset {
isArchived.hashCode ^
isTrashed.hashCode ^
stackCount.hashCode ^
- stackParentId.hashCode;
+ stackPrimaryAssetId.hashCode ^
+ stackId.hashCode;
/// Returns `true` if this [Asset] can updated with values from parameter [a]
bool canUpdate(Asset a) {
@@ -269,7 +273,6 @@ class Asset {
width == null && a.width != null ||
height == null && a.height != null ||
livePhotoVideoId == null && a.livePhotoVideoId != null ||
- stackParentId == null && a.stackParentId != null ||
isFavorite != a.isFavorite ||
isArchived != a.isArchived ||
isTrashed != a.isTrashed ||
@@ -278,10 +281,9 @@ class Asset {
a.exifInfo?.longitude != exifInfo?.longitude ||
// no local stack count or different count from remote
a.thumbhash != thumbhash ||
- ((stackCount == null && a.stackCount != null) ||
- (stackCount != null &&
- a.stackCount != null &&
- stackCount != a.stackCount));
+ stackId != a.stackId ||
+ stackCount != a.stackCount ||
+ stackPrimaryAssetId == null && a.stackPrimaryAssetId != null;
}
/// Returns a new [Asset] with values from this and merged & updated with [a]
@@ -311,9 +313,11 @@ class Asset {
id: id,
remoteId: remoteId,
livePhotoVideoId: livePhotoVideoId,
- // workaround to nullify stackParentId for the parent asset until we refactor the mobile app
+ // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app
// stack handling to properly handle it
- stackParentId: stackParentId == remoteId ? null : stackParentId,
+ stackId: stackId,
+ stackPrimaryAssetId:
+ stackPrimaryAssetId == remoteId ? null : stackPrimaryAssetId,
stackCount: stackCount,
isFavorite: isFavorite,
isArchived: isArchived,
@@ -330,9 +334,12 @@ class Asset {
width: a.width,
height: a.height,
livePhotoVideoId: a.livePhotoVideoId,
- // workaround to nullify stackParentId for the parent asset until we refactor the mobile app
+ // workaround to nullify stackPrimaryAssetId for the parent asset until we refactor the mobile app
// stack handling to properly handle it
- stackParentId: a.stackParentId == a.remoteId ? null : a.stackParentId,
+ stackId: a.stackId,
+ stackPrimaryAssetId: a.stackPrimaryAssetId == a.remoteId
+ ? null
+ : a.stackPrimaryAssetId,
stackCount: a.stackCount,
// isFavorite + isArchived are not set by device-only assets
isFavorite: a.isFavorite,
@@ -374,7 +381,8 @@ class Asset {
bool? isTrashed,
bool? isOffline,
ExifInfo? exifInfo,
- String? stackParentId,
+ String? stackId,
+ String? stackPrimaryAssetId,
int? stackCount,
String? thumbhash,
}) =>
@@ -398,7 +406,8 @@ class Asset {
isTrashed: isTrashed ?? this.isTrashed,
isOffline: isOffline ?? this.isOffline,
exifInfo: exifInfo ?? this.exifInfo,
- stackParentId: stackParentId ?? this.stackParentId,
+ stackId: stackId ?? this.stackId,
+ stackPrimaryAssetId: stackPrimaryAssetId ?? this.stackPrimaryAssetId,
stackCount: stackCount ?? this.stackCount,
thumbhash: thumbhash ?? this.thumbhash,
);
@@ -445,8 +454,9 @@ class Asset {
"checksum": "$checksum",
"ownerId": $ownerId,
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
+ "stackId": "${stackId ?? "N/A"}",
+ "stackPrimaryAssetId": "${stackPrimaryAssetId ?? "N/A"}",
"stackCount": "$stackCount",
- "stackParentId": "${stackParentId ?? "N/A"}",
"fileCreatedAt": "$fileCreatedAt",
"fileModifiedAt": "$fileModifiedAt",
"updatedAt": "$updatedAt",
diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart
index 099e15eef15ca..23bf23604635d 100644
--- a/mobile/lib/entities/asset.entity.g.dart
+++ b/mobile/lib/entities/asset.entity.g.dart
@@ -92,29 +92,34 @@ const AssetSchema = CollectionSchema(
name: r'stackCount',
type: IsarType.long,
),
- r'stackParentId': PropertySchema(
+ r'stackId': PropertySchema(
id: 15,
- name: r'stackParentId',
+ name: r'stackId',
+ type: IsarType.string,
+ ),
+ r'stackPrimaryAssetId': PropertySchema(
+ id: 16,
+ name: r'stackPrimaryAssetId',
type: IsarType.string,
),
r'thumbhash': PropertySchema(
- id: 16,
+ id: 17,
name: r'thumbhash',
type: IsarType.string,
),
r'type': PropertySchema(
- id: 17,
+ id: 18,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
- id: 18,
+ id: 19,
name: r'updatedAt',
type: IsarType.dateTime,
),
r'width': PropertySchema(
- id: 19,
+ id: 20,
name: r'width',
type: IsarType.int,
)
@@ -205,7 +210,13 @@ int _assetEstimateSize(
}
}
{
- final value = object.stackParentId;
+ final value = object.stackId;
+ if (value != null) {
+ bytesCount += 3 + value.length * 3;
+ }
+ }
+ {
+ final value = object.stackPrimaryAssetId;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
@@ -240,11 +251,12 @@ void _assetSerialize(
writer.writeLong(offsets[12], object.ownerId);
writer.writeString(offsets[13], object.remoteId);
writer.writeLong(offsets[14], object.stackCount);
- writer.writeString(offsets[15], object.stackParentId);
- writer.writeString(offsets[16], object.thumbhash);
- writer.writeByte(offsets[17], object.type.index);
- writer.writeDateTime(offsets[18], object.updatedAt);
- writer.writeInt(offsets[19], object.width);
+ writer.writeString(offsets[15], object.stackId);
+ writer.writeString(offsets[16], object.stackPrimaryAssetId);
+ writer.writeString(offsets[17], object.thumbhash);
+ writer.writeByte(offsets[18], object.type.index);
+ writer.writeDateTime(offsets[19], object.updatedAt);
+ writer.writeInt(offsets[20], object.width);
}
Asset _assetDeserialize(
@@ -269,13 +281,14 @@ Asset _assetDeserialize(
localId: reader.readStringOrNull(offsets[11]),
ownerId: reader.readLong(offsets[12]),
remoteId: reader.readStringOrNull(offsets[13]),
- stackCount: reader.readLongOrNull(offsets[14]),
- stackParentId: reader.readStringOrNull(offsets[15]),
- thumbhash: reader.readStringOrNull(offsets[16]),
- type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ??
+ stackCount: reader.readLongOrNull(offsets[14]) ?? 0,
+ stackId: reader.readStringOrNull(offsets[15]),
+ stackPrimaryAssetId: reader.readStringOrNull(offsets[16]),
+ thumbhash: reader.readStringOrNull(offsets[17]),
+ type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ??
AssetType.other,
- updatedAt: reader.readDateTime(offsets[18]),
- width: reader.readIntOrNull(offsets[19]),
+ updatedAt: reader.readDateTime(offsets[19]),
+ width: reader.readIntOrNull(offsets[20]),
);
return object;
}
@@ -316,17 +329,19 @@ P _assetDeserializeProp(
case 13:
return (reader.readStringOrNull(offset)) as P;
case 14:
- return (reader.readLongOrNull(offset)) as P;
+ return (reader.readLongOrNull(offset) ?? 0) as P;
case 15:
return (reader.readStringOrNull(offset)) as P;
case 16:
return (reader.readStringOrNull(offset)) as P;
case 17:
+ return (reader.readStringOrNull(offset)) as P;
+ case 18:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
- case 18:
- return (reader.readDateTime(offset)) as P;
case 19:
+ return (reader.readDateTime(offset)) as P;
+ case 20:
return (reader.readIntOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -1859,24 +1874,8 @@ extension AssetQueryFilter on QueryBuilder {
});
}
- QueryBuilder stackCountIsNull() {
- return QueryBuilder.apply(this, (query) {
- return query.addFilterCondition(const FilterCondition.isNull(
- property: r'stackCount',
- ));
- });
- }
-
- QueryBuilder stackCountIsNotNull() {
- return QueryBuilder.apply(this, (query) {
- return query.addFilterCondition(const FilterCondition.isNotNull(
- property: r'stackCount',
- ));
- });
- }
-
QueryBuilder stackCountEqualTo(
- int? value) {
+ int value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'stackCount',
@@ -1886,7 +1885,7 @@ extension AssetQueryFilter on QueryBuilder {
}
QueryBuilder stackCountGreaterThan(
- int? value, {
+ int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
@@ -1899,7 +1898,7 @@ extension AssetQueryFilter on QueryBuilder {
}
QueryBuilder stackCountLessThan(
- int? value, {
+ int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
@@ -1912,8 +1911,8 @@ extension AssetQueryFilter on QueryBuilder {
}
QueryBuilder stackCountBetween(
- int? lower,
- int? upper, {
+ int lower,
+ int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
@@ -1928,36 +1927,36 @@ extension AssetQueryFilter on QueryBuilder {
});
}
- QueryBuilder stackParentIdIsNull() {
+ QueryBuilder stackIdIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
- property: r'stackParentId',
+ property: r'stackId',
));
});
}
- QueryBuilder stackParentIdIsNotNull() {
+ QueryBuilder stackIdIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
- property: r'stackParentId',
+ property: r'stackId',
));
});
}
- QueryBuilder stackParentIdEqualTo(
+ QueryBuilder stackIdEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
- property: r'stackParentId',
+ property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
- QueryBuilder stackParentIdGreaterThan(
+ QueryBuilder stackIdGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
@@ -1965,14 +1964,14 @@ extension AssetQueryFilter on QueryBuilder {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
- property: r'stackParentId',
+ property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
- QueryBuilder stackParentIdLessThan(
+ QueryBuilder stackIdLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
@@ -1980,14 +1979,14 @@ extension AssetQueryFilter on QueryBuilder {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
- property: r'stackParentId',
+ property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
- QueryBuilder stackParentIdBetween(
+ QueryBuilder stackIdBetween(
String? lower,
String? upper, {
bool includeLower = true,
@@ -1996,7 +1995,7 @@ extension AssetQueryFilter on QueryBuilder {
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
- property: r'stackParentId',
+ property: r'stackId',
lower: lower,
includeLower: includeLower,
upper: upper,
@@ -2006,69 +2005,221 @@ extension AssetQueryFilter on QueryBuilder {
});
}
- QueryBuilder stackParentIdStartsWith(
+ QueryBuilder stackIdStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
- property: r'stackParentId',
+ property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
- QueryBuilder stackParentIdEndsWith(
+ QueryBuilder stackIdEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
- property: r'stackParentId',
+ property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
- QueryBuilder stackParentIdContains(
+ QueryBuilder stackIdContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
- property: r'stackParentId',
+ property: r'stackId',
value: value,
caseSensitive: caseSensitive,
));
});
}
- QueryBuilder stackParentIdMatches(
+ QueryBuilder stackIdMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
- property: r'stackParentId',
+ property: r'stackId',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
- QueryBuilder stackParentIdIsEmpty() {
+ QueryBuilder stackIdIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
- property: r'stackParentId',
+ property: r'stackId',
value: '',
));
});
}
- QueryBuilder stackParentIdIsNotEmpty() {
+ QueryBuilder stackIdIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
- property: r'stackParentId',
+ property: r'stackId',
+ value: '',
+ ));
+ });
+ }
+
+ QueryBuilder
+ stackPrimaryAssetIdIsNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNull(
+ property: r'stackPrimaryAssetId',
+ ));
+ });
+ }
+
+ QueryBuilder
+ stackPrimaryAssetIdIsNotNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNotNull(
+ property: r'stackPrimaryAssetId',
+ ));
+ });
+ }
+
+ QueryBuilder stackPrimaryAssetIdEqualTo(
+ String? value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'stackPrimaryAssetId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ stackPrimaryAssetIdGreaterThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ include: include,
+ property: r'stackPrimaryAssetId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackPrimaryAssetIdLessThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.lessThan(
+ include: include,
+ property: r'stackPrimaryAssetId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackPrimaryAssetIdBetween(
+ String? lower,
+ String? upper, {
+ bool includeLower = true,
+ bool includeUpper = true,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.between(
+ property: r'stackPrimaryAssetId',
+ lower: lower,
+ includeLower: includeLower,
+ upper: upper,
+ includeUpper: includeUpper,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ stackPrimaryAssetIdStartsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.startsWith(
+ property: r'stackPrimaryAssetId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackPrimaryAssetIdEndsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.endsWith(
+ property: r'stackPrimaryAssetId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackPrimaryAssetIdContains(
+ String value,
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.contains(
+ property: r'stackPrimaryAssetId',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder stackPrimaryAssetIdMatches(
+ String pattern,
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.matches(
+ property: r'stackPrimaryAssetId',
+ wildcard: pattern,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ stackPrimaryAssetIdIsEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'stackPrimaryAssetId',
+ value: '',
+ ));
+ });
+ }
+
+ QueryBuilder
+ stackPrimaryAssetIdIsNotEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ property: r'stackPrimaryAssetId',
value: '',
));
});
@@ -2580,15 +2731,27 @@ extension AssetQuerySortBy on QueryBuilder {
});
}
- QueryBuilder sortByStackParentId() {
+ QueryBuilder sortByStackId() {
return QueryBuilder.apply(this, (query) {
- return query.addSortBy(r'stackParentId', Sort.asc);
+ return query.addSortBy(r'stackId', Sort.asc);
});
}
- QueryBuilder sortByStackParentIdDesc() {
+ QueryBuilder sortByStackIdDesc() {
return QueryBuilder.apply(this, (query) {
- return query.addSortBy(r'stackParentId', Sort.desc);
+ return query.addSortBy(r'stackId', Sort.desc);
+ });
+ }
+
+ QueryBuilder sortByStackPrimaryAssetId() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackPrimaryAssetId', Sort.asc);
+ });
+ }
+
+ QueryBuilder sortByStackPrimaryAssetIdDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackPrimaryAssetId', Sort.desc);
});
}
@@ -2834,15 +2997,27 @@ extension AssetQuerySortThenBy on QueryBuilder {
});
}
- QueryBuilder thenByStackParentId() {
+ QueryBuilder thenByStackId() {
return QueryBuilder.apply(this, (query) {
- return query.addSortBy(r'stackParentId', Sort.asc);
+ return query.addSortBy(r'stackId', Sort.asc);
});
}
- QueryBuilder thenByStackParentIdDesc() {
+ QueryBuilder thenByStackIdDesc() {
return QueryBuilder.apply(this, (query) {
- return query.addSortBy(r'stackParentId', Sort.desc);
+ return query.addSortBy(r'stackId', Sort.desc);
+ });
+ }
+
+ QueryBuilder thenByStackPrimaryAssetId() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackPrimaryAssetId', Sort.asc);
+ });
+ }
+
+ QueryBuilder thenByStackPrimaryAssetIdDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'stackPrimaryAssetId', Sort.desc);
});
}
@@ -2992,10 +3167,17 @@ extension AssetQueryWhereDistinct on QueryBuilder {
});
}
- QueryBuilder distinctByStackParentId(
+ QueryBuilder distinctByStackId(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
- return query.addDistinctBy(r'stackParentId',
+ return query.addDistinctBy(r'stackId', caseSensitive: caseSensitive);
+ });
+ }
+
+ QueryBuilder distinctByStackPrimaryAssetId(
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addDistinctBy(r'stackPrimaryAssetId',
caseSensitive: caseSensitive);
});
}
@@ -3117,15 +3299,21 @@ extension AssetQueryProperty on QueryBuilder {
});
}
- QueryBuilder stackCountProperty() {
+ QueryBuilder stackCountProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'stackCount');
});
}
- QueryBuilder stackParentIdProperty() {
+ QueryBuilder stackIdProperty() {
return QueryBuilder.apply(this, (query) {
- return query.addPropertyName(r'stackParentId');
+ return query.addPropertyName(r'stackId');
+ });
+ }
+
+ QueryBuilder stackPrimaryAssetIdProperty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addPropertyName(r'stackPrimaryAssetId');
});
}
diff --git a/mobile/lib/entities/store.entity.dart b/mobile/lib/entities/store.entity.dart
index a84f9800019c3..1dda2b9a12a03 100644
--- a/mobile/lib/entities/store.entity.dart
+++ b/mobile/lib/entities/store.entity.dart
@@ -234,6 +234,8 @@ enum StoreKey {
primaryColor(128, type: String),
dynamicTheme(129, type: bool),
colorfulInterface(130, type: bool),
+
+ syncAlbums(131, type: bool),
;
const StoreKey(
diff --git a/mobile/lib/models/backup/backup_candidate.model.dart b/mobile/lib/models/backup/backup_candidate.model.dart
new file mode 100644
index 0000000000000..5ef15167455df
--- /dev/null
+++ b/mobile/lib/models/backup/backup_candidate.model.dart
@@ -0,0 +1,19 @@
+import 'package:photo_manager/photo_manager.dart';
+
+class BackupCandidate {
+ BackupCandidate({required this.asset, required this.albumNames});
+
+ AssetEntity asset;
+ List albumNames;
+
+ @override
+ int get hashCode => asset.hashCode;
+
+ @override
+ bool operator ==(Object other) {
+ if (other is! BackupCandidate) {
+ return false;
+ }
+ return asset == other.asset;
+ }
+}
diff --git a/mobile/lib/models/backup/backup_state.model.dart b/mobile/lib/models/backup/backup_state.model.dart
index bb693a5b75f7a..d829f411fc355 100644
--- a/mobile/lib/models/backup/backup_state.model.dart
+++ b/mobile/lib/models/backup/backup_state.model.dart
@@ -2,7 +2,7 @@
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
-import 'package:photo_manager/photo_manager.dart';
+import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
@@ -41,7 +41,7 @@ class BackUpState {
final Set excludedBackupAlbums;
/// Assets that are not overlapping in selected backup albums and excluded backup albums
- final Set allUniqueAssets;
+ final Set allUniqueAssets;
/// All assets from the selected albums that have been backup
final Set selectedAlbumsBackupAssetsIds;
@@ -94,7 +94,7 @@ class BackUpState {
List? availableAlbums,
Set? selectedBackupAlbums,
Set? excludedBackupAlbums,
- Set? allUniqueAssets,
+ Set? allUniqueAssets,
Set? selectedAlbumsBackupAssetsIds,
CurrentUploadAsset? currentUploadAsset,
}) {
diff --git a/mobile/lib/models/backup/success_upload_asset.model.dart b/mobile/lib/models/backup/success_upload_asset.model.dart
new file mode 100644
index 0000000000000..045715e8cbbda
--- /dev/null
+++ b/mobile/lib/models/backup/success_upload_asset.model.dart
@@ -0,0 +1,42 @@
+import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
+
+class SuccessUploadAsset {
+ final BackupCandidate candidate;
+ final String remoteAssetId;
+ final bool isDuplicate;
+
+ SuccessUploadAsset({
+ required this.candidate,
+ required this.remoteAssetId,
+ required this.isDuplicate,
+ });
+
+ SuccessUploadAsset copyWith({
+ BackupCandidate? candidate,
+ String? remoteAssetId,
+ bool? isDuplicate,
+ }) {
+ return SuccessUploadAsset(
+ candidate: candidate ?? this.candidate,
+ remoteAssetId: remoteAssetId ?? this.remoteAssetId,
+ isDuplicate: isDuplicate ?? this.isDuplicate,
+ );
+ }
+
+ @override
+ String toString() =>
+ 'SuccessUploadAsset(asset: $candidate, remoteAssetId: $remoteAssetId, isDuplicate: $isDuplicate)';
+
+ @override
+ bool operator ==(covariant SuccessUploadAsset other) {
+ if (identical(this, other)) return true;
+
+ return other.candidate == candidate &&
+ other.remoteAssetId == remoteAssetId &&
+ other.isDuplicate == isDuplicate;
+ }
+
+ @override
+ int get hashCode =>
+ candidate.hashCode ^ remoteAssetId.hashCode ^ isDuplicate.hashCode;
+}
diff --git a/mobile/lib/pages/backup/backup_album_selection.page.dart b/mobile/lib/pages/backup/backup_album_selection.page.dart
index 9f3e387755e85..8dccece325d8f 100644
--- a/mobile/lib/pages/backup/backup_album_selection.page.dart
+++ b/mobile/lib/pages/backup/backup_album_selection.page.dart
@@ -4,19 +4,24 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
+import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
+import 'package:immich_mobile/services/app_settings.service.dart';
+import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/backup/album_info_card.dart';
import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
+import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@RoutePage()
class BackupAlbumSelectionPage extends HookConsumerWidget {
const BackupAlbumSelectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
- // final availableAlbums = ref.watch(backupProvider).availableAlbums;
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
+ final enableSyncUploadAlbum =
+ useAppSettingsState(AppSettingsEnum.syncAlbums);
final isDarkTheme = context.isDarkTheme;
final albums = ref.watch(backupProvider).availableAlbums;
@@ -144,47 +149,14 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
}).toSet();
}
- // buildSearchBar() {
- // return Padding(
- // padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
- // child: TextFormField(
- // onChanged: (searchValue) {
- // // if (searchValue.isEmpty) {
- // // albums = availableAlbums;
- // // } else {
- // // albums.value = availableAlbums
- // // .where(
- // // (album) => album.name
- // // .toLowerCase()
- // // .contains(searchValue.toLowerCase()),
- // // )
- // // .toList();
- // // }
- // },
- // decoration: InputDecoration(
- // contentPadding: const EdgeInsets.symmetric(
- // horizontal: 8.0,
- // vertical: 8.0,
- // ),
- // hintText: "Search",
- // hintStyle: TextStyle(
- // color: isDarkTheme ? Colors.white : Colors.grey,
- // fontSize: 14.0,
- // ),
- // prefixIcon: const Icon(
- // Icons.search,
- // color: Colors.grey,
- // ),
- // border: OutlineInputBorder(
- // borderRadius: BorderRadius.circular(10),
- // borderSide: BorderSide.none,
- // ),
- // filled: true,
- // fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
- // ),
- // ),
- // );
- // }
+ handleSyncAlbumToggle(bool isEnable) async {
+ if (isEnable) {
+ await ref.read(albumProvider.notifier).getAllAlbums();
+ for (final album in selectedBackupAlbums) {
+ await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
+ }
+ }
+ }
return Scaffold(
appBar: AppBar(
@@ -226,6 +198,20 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
),
),
+ SettingsSwitchListTile(
+ valueNotifier: enableSyncUploadAlbum,
+ title: "sync_albums".tr(),
+ subtitle: "sync_upload_album_setting_subtitle".tr(),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16),
+ titleStyle: context.textTheme.bodyLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ subtitleStyle: context.textTheme.labelLarge?.copyWith(
+ color: context.colorScheme.primary,
+ ),
+ onChanged: handleSyncAlbumToggle,
+ ),
+
ListTile(
title: Text(
"backup_album_selection_page_albums_device".tr(
diff --git a/mobile/lib/pages/common/create_album.page.dart b/mobile/lib/pages/common/create_album.page.dart
index 51282d8dd6ad4..1fd860520d5c7 100644
--- a/mobile/lib/pages/common/create_album.page.dart
+++ b/mobile/lib/pages/common/create_album.page.dart
@@ -52,6 +52,7 @@ class CreateAlbumPage extends HookConsumerWidget {
if (albumTitleController.text.isEmpty) {
albumTitleController.text = 'create_album_page_untitled'.tr();
+ isAlbumTitleEmpty.value = false;
ref
.watch(albumTitleProvider.notifier)
.setAlbumTitle('create_album_page_untitled'.tr());
@@ -191,6 +192,7 @@ class CreateAlbumPage extends HookConsumerWidget {
}
createNonSharedAlbum() async {
+ onBackgroundTapped();
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
ref.watch(albumTitleProvider),
selectedAssets.value,
@@ -238,15 +240,16 @@ class CreateAlbumPage extends HookConsumerWidget {
),
if (!isSharedAlbum)
TextButton(
- onPressed: albumTitleController.text.isNotEmpty &&
- selectedAssets.value.isNotEmpty
+ onPressed: albumTitleController.text.isNotEmpty
? createNonSharedAlbum
: null,
child: Text(
'create_shared_album_page_create'.tr(),
style: TextStyle(
fontWeight: FontWeight.bold,
- color: context.primaryColor,
+ color: albumTitleController.text.isNotEmpty
+ ? context.primaryColor
+ : context.themeData.disabledColor,
),
),
),
diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart
index cc62620dfb239..d8ea7cd89b47f 100644
--- a/mobile/lib/pages/common/gallery_viewer.page.dart
+++ b/mobile/lib/pages/common/gallery_viewer.page.dart
@@ -68,7 +68,7 @@ class GalleryViewerPage extends HookConsumerWidget {
});
final stackIndex = useState(-1);
- final stack = showStack && currentAsset.stackChildrenCount > 0
+ final stack = showStack && currentAsset.stackCount > 0
? ref.watch(assetStackStateProvider(currentAsset))
: [];
final stackElements = showStack ? [currentAsset, ...stack] : [];
diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart
index 8a21cdf76908f..729b59ded5911 100644
--- a/mobile/lib/pages/editing/crop.page.dart
+++ b/mobile/lib/pages/editing/crop.page.dart
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart';
import 'package:crop_image/crop_image.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
+import 'package:immich_mobile/entities/asset.entity.dart';
import 'edit.page.dart';
+import 'package:easy_localization/easy_localization.dart';
import 'package:auto_route/auto_route.dart';
/// A widget for cropping an image.
@@ -14,7 +17,8 @@ import 'package:auto_route/auto_route.dart';
@RoutePage()
class CropImagePage extends HookWidget {
final Image image;
- const CropImagePage({super.key, required this.image});
+ final Asset asset;
+ const CropImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
@@ -23,29 +27,37 @@ class CropImagePage extends HookWidget {
return Scaffold(
appBar: AppBar(
- backgroundColor: Theme.of(context).bottomAppBarTheme.color,
- leading: CloseButton(color: Theme.of(context).iconTheme.color),
+ backgroundColor: context.scaffoldBackgroundColor,
+ title: Text("crop".tr()),
+ leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(
Icons.done_rounded,
- color: Theme.of(context).iconTheme.color,
+ color: context.primaryColor,
size: 24,
),
onPressed: () async {
final croppedImage = await cropController.croppedImage();
- context.pushRoute(EditImageRoute(image: croppedImage));
+ context.pushRoute(
+ EditImageRoute(
+ asset: asset,
+ image: croppedImage,
+ isEdited: true,
+ ),
+ );
},
),
],
),
+ backgroundColor: context.scaffoldBackgroundColor,
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
- width: double.infinity,
+ width: constraints.maxWidth * 0.9,
height: constraints.maxHeight * 0.6,
child: CropImage(
controller: cropController,
@@ -57,7 +69,7 @@ class CropImagePage extends HookWidget {
child: Container(
width: double.infinity,
decoration: BoxDecoration(
- color: Theme.of(context).bottomAppBarTheme.color,
+ color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
@@ -188,7 +200,7 @@ class _AspectRatioButton extends StatelessWidget {
icon: Icon(
iconData,
color: aspectRatio.value == ratio
- ? Colors.indigo
+ ? context.primaryColor
: Theme.of(context).iconTheme.color,
),
onPressed: () {
@@ -197,7 +209,7 @@ class _AspectRatioButton extends StatelessWidget {
cropController.aspectRatio = ratio;
},
),
- Text(label, style: Theme.of(context).textTheme.bodyMedium),
+ Text(label, style: context.textTheme.displayMedium),
],
);
}
diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart
index 22fb345e0f706..c81e84877b208 100644
--- a/mobile/lib/pages/editing/edit.page.dart
+++ b/mobile/lib/pages/editing/edit.page.dart
@@ -7,12 +7,15 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
+import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:auto_route/auto_route.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:path/path.dart' as p;
/// A stateless widget that provides functionality for editing an image.
///
@@ -24,18 +27,16 @@ import 'package:immich_mobile/providers/album/album.provider.dart';
@immutable
@RoutePage()
class EditImagePage extends ConsumerWidget {
- final Asset? asset;
- final Image? image;
+ final Asset asset;
+ final Image image;
+ final bool isEdited;
const EditImagePage({
super.key,
- this.image,
- this.asset,
- }) : assert(
- (image != null && asset == null) || (image == null && asset != null),
- 'Must supply one of asset or image',
- );
-
+ required this.asset,
+ required this.image,
+ required this.isEdited,
+ });
Future _imageToUint8List(Image image) async {
final Completer completer = Completer();
image.image.resolve(const ImageConfiguration()).addListener(
@@ -58,83 +59,102 @@ class EditImagePage extends ConsumerWidget {
return completer.future;
}
+ Future _saveEditedImage(
+ BuildContext context,
+ Asset asset,
+ Image image,
+ WidgetRef ref,
+ ) async {
+ try {
+ final Uint8List imageData = await _imageToUint8List(image);
+ await PhotoManager.editor.saveImage(
+ imageData,
+ title: "${p.withoutExtension(asset.fileName)}_edited.jpg",
+ );
+ await ref.read(albumProvider.notifier).getDeviceAlbums();
+ Navigator.of(context).popUntil((route) => route.isFirst);
+ ImmichToast.show(
+ durationInSecond: 3,
+ context: context,
+ msg: 'Image Saved!',
+ gravity: ToastGravity.CENTER,
+ );
+ } catch (e) {
+ ImmichToast.show(
+ durationInSecond: 6,
+ context: context,
+ msg: "error_saving_image".tr(args: [e.toString()]),
+ gravity: ToastGravity.CENTER,
+ );
+ }
+ }
+
@override
Widget build(BuildContext context, WidgetRef ref) {
- final ImageProvider provider = (asset != null)
- ? ImmichImage.imageProvider(asset: asset!)
- : (image != null)
- ? image!.image
- : throw Exception('Invalid image source type');
-
- final Image imageWidget = (asset != null)
- ? Image(image: ImmichImage.imageProvider(asset: asset!))
- : (image != null)
- ? image!
- : throw Exception('Invalid image source type');
+ final Image imageWidget =
+ Image(image: ImmichImage.imageProvider(asset: asset));
return Scaffold(
appBar: AppBar(
- backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
+ title: Text("edit_image_title".tr()),
+ backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(
Icons.close_rounded,
- color: Theme.of(context).iconTheme.color,
+ color: context.primaryColor,
size: 24,
),
onPressed: () =>
Navigator.of(context).popUntil((route) => route.isFirst),
),
actions: [
- if (image != null)
- TextButton(
- onPressed: () async {
- try {
- final Uint8List imageData = await _imageToUint8List(image!);
- ImmichToast.show(
- durationInSecond: 3,
- context: context,
- msg: 'Image Saved!',
- gravity: ToastGravity.CENTER,
- );
-
- await PhotoManager.editor.saveImage(
- imageData,
- title: '${asset!.fileName}_edited.jpg',
- );
- await ref.read(albumProvider.notifier).getDeviceAlbums();
- Navigator.of(context).popUntil((route) => route.isFirst);
- } catch (e) {
- ImmichToast.show(
- durationInSecond: 6,
- context: context,
- msg: 'Error: ${e.toString()}',
- gravity: ToastGravity.BOTTOM,
- );
- }
- },
- child: Text(
- 'Save to gallery',
- style: Theme.of(context).textTheme.displayMedium,
+ TextButton(
+ onPressed: isEdited
+ ? () => _saveEditedImage(context, asset, image, ref)
+ : null,
+ child: Text(
+ "save_to_gallery".tr(),
+ style: TextStyle(
+ color: isEdited ? context.primaryColor : Colors.grey,
),
),
+ ),
],
),
- body: Column(
- children: [
- Expanded(
- child: Image(image: provider),
+ backgroundColor: context.scaffoldBackgroundColor,
+ body: Center(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ maxHeight: MediaQuery.of(context).size.height * 0.7,
+ maxWidth: MediaQuery.of(context).size.width * 0.9,
),
- Container(
- height: 80,
- color: Theme.of(context).bottomAppBarTheme.color,
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(7),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.2),
+ spreadRadius: 2,
+ blurRadius: 10,
+ offset: const Offset(0, 3),
+ ),
+ ],
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(7),
+ child: Image(
+ image: image.image,
+ fit: BoxFit.contain,
+ ),
+ ),
),
- ],
+ ),
),
bottomNavigationBar: Container(
- height: 80,
- margin: const EdgeInsets.only(bottom: 20, right: 10, left: 10, top: 10),
+ height: 70,
+ margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
decoration: BoxDecoration(
- color: Theme.of(context).bottomAppBarTheme.color,
+ color: context.scaffoldBackgroundColor,
borderRadius: BorderRadius.circular(30),
),
child: Column(
@@ -146,12 +166,15 @@ class EditImagePage extends ConsumerWidget {
? Icons.crop_rotate_rounded
: Icons.crop_rotate_rounded,
color: Theme.of(context).iconTheme.color,
+ size: 25,
),
onPressed: () {
- context.pushRoute(CropImageRoute(image: imageWidget));
+ context.pushRoute(
+ CropImageRoute(asset: asset, image: imageWidget),
+ );
},
),
- Text('Crop', style: Theme.of(context).textTheme.displayMedium),
+ Text("crop".tr(), style: context.textTheme.displayMedium),
],
),
),
diff --git a/mobile/lib/pages/login/login.page.dart b/mobile/lib/pages/login/login.page.dart
index b305b5fc534d6..8045ae649fc9a 100644
--- a/mobile/lib/pages/login/login.page.dart
+++ b/mobile/lib/pages/login/login.page.dart
@@ -29,7 +29,7 @@ class LoginPage extends HookConsumerWidget {
);
return Scaffold(
- body: const LoginForm(),
+ body: LoginForm(),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16.0),
diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart
index 8251d5e66bf33..ed9dc07f5e5c0 100644
--- a/mobile/lib/providers/album/album.provider.dart
+++ b/mobile/lib/providers/album/album.provider.dart
@@ -23,6 +23,7 @@ class AlbumNotifier extends StateNotifier> {
});
_streamSub = query.watch().listen((data) => state = data);
}
+
final AlbumService _albumService;
late final StreamSubscription> _streamSub;
@@ -41,6 +42,23 @@ class AlbumNotifier extends StateNotifier> {
) =>
_albumService.createAlbum(albumTitle, assets, []);
+ Future getAlbumByName(String albumName, {bool remoteOnly = false}) =>
+ _albumService.getAlbumByName(albumName, remoteOnly);
+
+ /// Create an album on the server with the same name as the selected album for backup
+ /// First this will check if the album already exists on the server with name
+ /// If it does not exist, it will create the album on the server
+ Future createSyncAlbum(
+ String albumName,
+ ) async {
+ final album = await getAlbumByName(albumName, remoteOnly: true);
+ if (album != null) {
+ return;
+ }
+
+ await createAlbum(albumName, {});
+ }
+
@override
void dispose() {
_streamSub.cancel();
diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart
index a0a3879db54f1..3c1a5ecc0119b 100644
--- a/mobile/lib/providers/asset.provider.dart
+++ b/mobile/lib/providers/asset.provider.dart
@@ -360,7 +360,7 @@ QueryBuilder? getRemoteAssetQuery(WidgetRef ref) {
.filter()
.ownerIdEqualTo(userId)
.isTrashedEqualTo(false)
- .stackParentIdIsNull()
+ .stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc();
}
@@ -374,6 +374,6 @@ QueryBuilder _commonFilterAndSort(
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
- .stackParentIdIsNull()
+ .stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc();
}
diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart
index 0883ed92dbc10..c3e4414b3935a 100644
--- a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart
+++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart
@@ -48,7 +48,7 @@ final assetStackProvider =
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
- .stackParentIdEqualTo(asset.remoteId)
+ .stackPrimaryAssetIdEqualTo(asset.remoteId)
.sortByFileCreatedAtDesc()
.findAll();
});
diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart
index 5846bb78cc3e8..b56e71b11b3f6 100644
--- a/mobile/lib/providers/authentication.provider.dart
+++ b/mobile/lib/providers/authentication.provider.dart
@@ -170,8 +170,10 @@ class AuthenticationNotifier extends StateNotifier {
UserPreferencesResponseDto? userPreferences;
try {
final responses = await Future.wait([
- _apiService.usersApi.getMyUser(),
- _apiService.usersApi.getMyPreferences(),
+ _apiService.usersApi.getMyUser().timeout(const Duration(seconds: 7)),
+ _apiService.usersApi
+ .getMyPreferences()
+ .timeout(const Duration(seconds: 7)),
]);
userResponse = responses[0] as UserAdminResponseDto;
userPreferences = responses[1] as UserPreferencesResponseDto;
@@ -190,6 +192,9 @@ class AuthenticationNotifier extends StateNotifier {
error,
stackTrace,
);
+ debugPrint(
+ "Error getting user information from the server [CATCH ALL] $error $stackTrace",
+ );
}
// If the user information is successfully retrieved, update the store
diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart
index 58027e3b941e0..02f1f07904f97 100644
--- a/mobile/lib/providers/backup/backup.provider.dart
+++ b/mobile/lib/providers/backup/backup.provider.dart
@@ -2,13 +2,16 @@ import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
+import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
+import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
@@ -290,8 +293,8 @@ class BackupNotifier extends StateNotifier {
///
Future _updateBackupAssetCount() async {
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
- final Set assetsFromSelectedAlbums = {};
- final Set assetsFromExcludedAlbums = {};
+ final Set assetsFromSelectedAlbums = {};
+ final Set assetsFromExcludedAlbums = {};
for (final album in state.selectedBackupAlbums) {
final assetCount = await album.albumEntity.assetCountAsync;
@@ -304,7 +307,27 @@ class BackupNotifier extends StateNotifier {
start: 0,
end: assetCount,
);
- assetsFromSelectedAlbums.addAll(assets);
+
+ // Add album's name to the asset info
+ for (final asset in assets) {
+ List albumNames = [album.name];
+
+ final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull(
+ (a) => a.asset.id == asset.id,
+ );
+
+ if (existingAsset != null) {
+ albumNames.addAll(existingAsset.albumNames);
+ assetsFromSelectedAlbums.remove(existingAsset);
+ }
+
+ assetsFromSelectedAlbums.add(
+ BackupCandidate(
+ asset: asset,
+ albumNames: albumNames,
+ ),
+ );
+ }
}
for (final album in state.excludedBackupAlbums) {
@@ -318,11 +341,17 @@ class BackupNotifier extends StateNotifier {
start: 0,
end: assetCount,
);
- assetsFromExcludedAlbums.addAll(assets);
+
+ for (final asset in assets) {
+ assetsFromExcludedAlbums.add(
+ BackupCandidate(asset: asset, albumNames: [album.name]),
+ );
+ }
}
- final Set allUniqueAssets =
+ final Set allUniqueAssets =
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
+
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
@@ -331,14 +360,14 @@ class BackupNotifier extends StateNotifier {
// Find asset that were backup from selected albums
final Set selectedAlbumsBackupAssets =
- Set.from(allUniqueAssets.map((e) => e.id));
+ Set.from(allUniqueAssets.map((e) => e.asset.id));
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
- (asset) => duplicatedAssetIds.contains(asset.id),
+ (candidate) => duplicatedAssetIds.contains(candidate.asset.id),
);
if (allUniqueAssets.isEmpty) {
@@ -433,10 +462,10 @@ class BackupNotifier extends StateNotifier {
return;
}
- Set assetsWillBeBackup = Set.from(state.allUniqueAssets);
+ Set assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up
for (final assetId in state.allAssetsInDatabase) {
- assetsWillBeBackup.removeWhere((e) => e.id == assetId);
+ assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId);
}
if (assetsWillBeBackup.isEmpty) {
@@ -456,11 +485,11 @@ class BackupNotifier extends StateNotifier {
await _backupService.backupAsset(
assetsWillBeBackup,
state.cancelToken,
- pmProgressHandler,
- _onAssetUploaded,
- _onUploadProgress,
- _onSetCurrentBackupAsset,
- _onBackupError,
+ pmProgressHandler: pmProgressHandler,
+ onSuccess: _onAssetUploaded,
+ onProgress: _onUploadProgress,
+ onCurrentAsset: _onSetCurrentBackupAsset,
+ onError: _onBackupError,
);
await notifyBackgroundServiceCanRun();
} else {
@@ -497,34 +526,36 @@ class BackupNotifier extends StateNotifier {
);
}
- void _onAssetUploaded(
- String deviceAssetId,
- String deviceId,
- bool isDuplicated,
- ) {
- if (isDuplicated) {
+ void _onAssetUploaded(SuccessUploadAsset result) async {
+ if (result.isDuplicate) {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
- .where((asset) => asset.id != deviceAssetId)
+ .where(
+ (candidate) => candidate.asset.id != result.candidate.asset.id,
+ )
.toSet(),
);
} else {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
- deviceAssetId,
+ result.candidate.asset.id,
},
- allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
+ allAssetsInDatabase: [
+ ...state.allAssetsInDatabase,
+ result.candidate.asset.id,
+ ],
);
}
if (state.allUniqueAssets.length -
state.selectedAlbumsBackupAssetsIds.length ==
0) {
- final latestAssetBackup =
- state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
- (v, e) => e.isAfter(v) ? e : v,
- );
+ final latestAssetBackup = state.allUniqueAssets
+ .map((candidate) => candidate.asset.modifiedDateTime)
+ .reduce(
+ (v, e) => e.isAfter(v) ? e : v,
+ );
state = state.copyWith(
selectedBackupAlbums: state.selectedBackupAlbums
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart
index b446711226324..a76b56fea7f8a 100644
--- a/mobile/lib/providers/backup/manual_upload.provider.dart
+++ b/mobile/lib/providers/backup/manual_upload.provider.dart
@@ -6,6 +6,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
+import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
@@ -22,6 +24,7 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
+import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';
@@ -31,6 +34,7 @@ final manualUploadProvider =
return ManualUploadNotifier(
ref.watch(localNotificationService),
ref.watch(backupProvider.notifier),
+ ref.watch(backupServiceProvider),
ref,
);
});
@@ -39,11 +43,13 @@ class ManualUploadNotifier extends StateNotifier {
final Logger _log = Logger("ManualUploadNotifier");
final LocalNotificationService _localNotificationService;
final BackupNotifier _backupProvider;
+ final BackupService _backupService;
final Ref ref;
ManualUploadNotifier(
this._localNotificationService,
this._backupProvider,
+ this._backupService,
this.ref,
) : super(
ManualUploadState(
@@ -115,11 +121,7 @@ class ManualUploadNotifier extends StateNotifier {
}
}
- void _onAssetUploaded(
- String deviceAssetId,
- String deviceId,
- bool isDuplicated,
- ) {
+ void _onAssetUploaded(SuccessUploadAsset result) {
state = state.copyWith(successfulUploads: state.successfulUploads + 1);
_backupProvider.updateDiskInfo();
}
@@ -209,9 +211,23 @@ class ManualUploadNotifier extends StateNotifier {
);
}
- Set allUploadAssets = allAssetsFromDevice.nonNulls.toSet();
+ final selectedBackupAlbums =
+ _backupService.selectedAlbumsQuery().findAllSync();
+ final excludedBackupAlbums =
+ _backupService.excludedAlbumsQuery().findAllSync();
- if (allUploadAssets.isEmpty) {
+ // Get candidates from selected albums and excluded albums
+ Set candidates =
+ await _backupService.buildUploadCandidates(
+ selectedBackupAlbums,
+ excludedBackupAlbums,
+ );
+
+ // Extrack candidate from allAssetsFromDevice.nonNulls
+ final uploadAssets = candidates
+ .where((e) => allAssetsFromDevice.nonNulls.contains(e.asset));
+
+ if (uploadAssets.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process");
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
return false;
@@ -221,7 +237,7 @@ class ManualUploadNotifier extends StateNotifier {
progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
- totalAssetsToUpload: allUploadAssets.length,
+ totalAssetsToUpload: uploadAssets.length,
successfulUploads: 0,
currentAssetIndex: 0,
currentUploadAsset: CurrentUploadAsset(
@@ -250,13 +266,13 @@ class ManualUploadNotifier extends StateNotifier {
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final bool ok = await ref.read(backupServiceProvider).backupAsset(
- allUploadAssets,
+ uploadAssets,
state.cancelToken,
- pmProgressHandler,
- _onAssetUploaded,
- _onProgress,
- _onSetCurrentBackupAsset,
- _onAssetUploadError,
+ pmProgressHandler: pmProgressHandler,
+ onSuccess: _onAssetUploaded,
+ onProgress: _onProgress,
+ onCurrentAsset: _onSetCurrentBackupAsset,
+ onError: _onAssetUploadError,
);
// Close detailed notification
diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart
index a4259676c7a6d..90fc4cb0fe96c 100644
--- a/mobile/lib/routing/router.gr.dart
+++ b/mobile/lib/routing/router.gr.dart
@@ -613,12 +613,14 @@ class CropImageRoute extends PageRouteInfo {
CropImageRoute({
Key? key,
required Image image,
+ required Asset asset,
List? children,
}) : super(
CropImageRoute.name,
args: CropImageRouteArgs(
key: key,
image: image,
+ asset: asset,
),
initialChildren: children,
);
@@ -632,6 +634,7 @@ class CropImageRoute extends PageRouteInfo {
return CropImagePage(
key: args.key,
image: args.image,
+ asset: args.asset,
);
},
);
@@ -641,15 +644,18 @@ class CropImageRouteArgs {
const CropImageRouteArgs({
this.key,
required this.image,
+ required this.asset,
});
final Key? key;
final Image image;
+ final Asset asset;
+
@override
String toString() {
- return 'CropImageRouteArgs{key: $key, image: $image}';
+ return 'CropImageRouteArgs{key: $key, image: $image, asset: $asset}';
}
}
@@ -658,15 +664,17 @@ class CropImageRouteArgs {
class EditImageRoute extends PageRouteInfo {
EditImageRoute({
Key? key,
- Image? image,
- Asset? asset,
+ required Asset asset,
+ required Image image,
+ required bool isEdited,
List? children,
}) : super(
EditImageRoute.name,
args: EditImageRouteArgs(
key: key,
- image: image,
asset: asset,
+ image: image,
+ isEdited: isEdited,
),
initialChildren: children,
);
@@ -676,12 +684,12 @@ class EditImageRoute extends PageRouteInfo {
static PageInfo page = PageInfo(
name,
builder: (data) {
- final args = data.argsAs(
- orElse: () => const EditImageRouteArgs());
+ final args = data.argsAs();
return EditImagePage(
key: args.key,
- image: args.image,
asset: args.asset,
+ image: args.image,
+ isEdited: args.isEdited,
);
},
);
@@ -690,19 +698,22 @@ class EditImageRoute extends PageRouteInfo {
class EditImageRouteArgs {
const EditImageRouteArgs({
this.key,
- this.image,
- this.asset,
+ required this.asset,
+ required this.image,
+ required this.isEdited,
});
final Key? key;
- final Image? image;
+ final Asset asset;
- final Asset? asset;
+ final Image image;
+
+ final bool isEdited;
@override
String toString() {
- return 'EditImageRouteArgs{key: $key, image: $image, asset: $asset}';
+ return 'EditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}';
}
}
diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart
index c2494680c7da5..ef56f9bf6c12a 100644
--- a/mobile/lib/services/album.service.dart
+++ b/mobile/lib/services/album.service.dart
@@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
-import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -28,7 +27,6 @@ final albumServiceProvider = Provider(
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
- ref.watch(backupServiceProvider),
),
);
@@ -37,7 +35,6 @@ class AlbumService {
final UserService _userService;
final SyncService _syncService;
final Isar _db;
- final BackupService _backupService;
final Logger _log = Logger('AlbumService');
Completer _localCompleter = Completer()..complete(false);
Completer _remoteCompleter = Completer()..complete(false);
@@ -47,9 +44,15 @@ class AlbumService {
this._userService,
this._syncService,
this._db,
- this._backupService,
);
+ QueryBuilder
+ selectedAlbumsQuery() =>
+ _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
+ QueryBuilder
+ excludedAlbumsQuery() =>
+ _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
+
/// Checks all selected device albums for changes of albums and their assets
/// Updates the local database and returns `true` if there were any changes
Future refreshDeviceAlbums() async {
@@ -63,9 +66,9 @@ class AlbumService {
bool changes = false;
try {
final List excludedIds =
- await _backupService.excludedAlbumsQuery().idProperty().findAll();
+ await excludedAlbumsQuery().idProperty().findAll();
final List selectedIds =
- await _backupService.selectedAlbumsQuery().idProperty().findAll();
+ await selectedAlbumsQuery().idProperty().findAll();
if (selectedIds.isEmpty) {
final numLocal = await _db.albums.where().localIdIsNotNull().count();
if (numLocal > 0) {
@@ -441,4 +444,33 @@ class AlbumService {
return false;
}
}
+
+ Future getAlbumByName(String name, bool remoteOnly) async {
+ return _db.albums
+ .filter()
+ .optional(remoteOnly, (q) => q.localIdIsNull())
+ .nameEqualTo(name)
+ .sharedEqualTo(false)
+ .findFirst();
+ }
+
+ ///
+ /// Add the uploaded asset to the selected albums
+ ///
+ Future syncUploadAlbums(
+ List albumNames,
+ List assetIds,
+ ) async {
+ for (final albumName in albumNames) {
+ Album? album = await getAlbumByName(albumName, true);
+ album ??= await createAlbum(albumName, []);
+
+ if (album != null && album.remoteId != null) {
+ await _apiService.albumsApi.addAssetsToAlbum(
+ album.remoteId!,
+ BulkIdsDto(ids: assetIds),
+ );
+ }
+ }
+ }
}
diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart
index c128a2c2fccf3..4a3cfb19a2a28 100644
--- a/mobile/lib/services/api.service.dart
+++ b/mobile/lib/services/api.service.dart
@@ -18,7 +18,7 @@ class ApiService implements Authentication {
late AlbumsApi albumsApi;
late AssetsApi assetsApi;
late SearchApi searchApi;
- late ServerInfoApi serverInfoApi;
+ late ServerApi serverInfoApi;
late MapApi mapApi;
late PartnersApi partnersApi;
late PeopleApi peopleApi;
@@ -29,6 +29,7 @@ class ApiService implements Authentication {
late ActivitiesApi activitiesApi;
late DownloadApi downloadApi;
late TrashApi trashApi;
+ late StacksApi stacksApi;
ApiService() {
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@@ -49,7 +50,7 @@ class ApiService implements Authentication {
oAuthApi = OAuthApi(_apiClient);
albumsApi = AlbumsApi(_apiClient);
assetsApi = AssetsApi(_apiClient);
- serverInfoApi = ServerInfoApi(_apiClient);
+ serverInfoApi = ServerApi(_apiClient);
searchApi = SearchApi(_apiClient);
mapApi = MapApi(_apiClient);
partnersApi = PartnersApi(_apiClient);
@@ -61,6 +62,7 @@ class ApiService implements Authentication {
activitiesApi = ActivitiesApi(_apiClient);
downloadApi = DownloadApi(_apiClient);
trashApi = TrashApi(_apiClient);
+ stacksApi = StacksApi(_apiClient);
}
Future resolveAndSetEndpoint(String serverUrl) async {
diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart
index bd254032159c0..8f773e1bb33a9 100644
--- a/mobile/lib/services/app_settings.service.dart
+++ b/mobile/lib/services/app_settings.service.dart
@@ -76,6 +76,7 @@ enum AppSettingsEnum {
false,
),
enableHapticFeedback(StoreKey.enableHapticFeedback, null, true),
+ syncAlbums(StoreKey.syncAlbums, null, false),
;
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart
index d37133a63b9c7..17508cba5153e 100644
--- a/mobile/lib/services/asset.service.dart
+++ b/mobile/lib/services/asset.service.dart
@@ -2,15 +2,20 @@
import 'dart:async';
+import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
+import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
+import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
+import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
+import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart';
import 'package:isar/isar.dart';
@@ -23,6 +28,8 @@ final assetServiceProvider = Provider(
ref.watch(apiServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(userServiceProvider),
+ ref.watch(backupServiceProvider),
+ ref.watch(albumServiceProvider),
ref.watch(dbProvider),
),
);
@@ -31,6 +38,8 @@ class AssetService {
final ApiService _apiService;
final SyncService _syncService;
final UserService _userService;
+ final BackupService _backupService;
+ final AlbumService _albumService;
final log = Logger('AssetService');
final Isar _db;
@@ -38,6 +47,8 @@ class AssetService {
this._apiService,
this._syncService,
this._userService,
+ this._backupService,
+ this._albumService,
this._db,
);
@@ -284,4 +295,64 @@ class AssetService {
return Future.value(null);
}
}
+
+ Future syncUploadedAssetToAlbums() async {
+ try {
+ final [selectedAlbums, excludedAlbums] = await Future.wait([
+ _backupService.selectedAlbumsQuery().findAll(),
+ _backupService.excludedAlbumsQuery().findAll(),
+ ]);
+
+ final candidates = await _backupService.buildUploadCandidates(
+ selectedAlbums,
+ excludedAlbums,
+ useTimeFilter: false,
+ );
+
+ final duplicates = await _apiService.assetsApi.checkExistingAssets(
+ CheckExistingAssetsDto(
+ deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
+ deviceId: Store.get(StoreKey.deviceId),
+ ),
+ );
+
+ if (duplicates != null) {
+ candidates
+ .removeWhere((c) => !duplicates.existingIds.contains(c.asset.id));
+ }
+
+ await refreshRemoteAssets();
+ final remoteAssets = await _db.assets
+ .where()
+ .localIdIsNotNull()
+ .filter()
+ .remoteIdIsNotNull()
+ .findAll();
+
+ /// Map
+ Map> assetToAlbums = {};
+
+ for (BackupCandidate candidate in candidates) {
+ final asset = remoteAssets.firstWhereOrNull(
+ (a) => a.localId == candidate.asset.id,
+ );
+
+ if (asset != null) {
+ for (final albumName in candidate.albumNames) {
+ assetToAlbums.putIfAbsent(albumName, () => []).add(asset.remoteId!);
+ }
+ }
+ }
+
+ // Upload assets to albums
+ for (final entry in assetToAlbums.entries) {
+ final albumName = entry.key;
+ final assetIds = entry.value;
+
+ await _albumService.syncUploadAlbums([albumName], assetIds);
+ }
+ } catch (error, stack) {
+ log.severe("Error while syncing uploaded asset to albums", error, stack);
+ }
+ }
}
diff --git a/mobile/lib/services/asset_stack.service.dart b/mobile/lib/services/asset_stack.service.dart
deleted file mode 100644
index 9eff495f3740f..0000000000000
--- a/mobile/lib/services/asset_stack.service.dart
+++ /dev/null
@@ -1,72 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/entities/asset.entity.dart';
-import 'package:immich_mobile/providers/api.provider.dart';
-import 'package:immich_mobile/services/api.service.dart';
-import 'package:openapi/api.dart';
-
-class AssetStackService {
- AssetStackService(this._api);
-
- final ApiService _api;
-
- Future updateStack(
- Asset parentAsset, {
- List? childrenToAdd,
- List? childrenToRemove,
- }) async {
- // Guard [local asset]
- if (parentAsset.remoteId == null) {
- return;
- }
-
- try {
- if (childrenToAdd != null) {
- final toAdd = childrenToAdd
- .where((e) => e.isRemote)
- .map((e) => e.remoteId!)
- .toList();
-
- await _api.assetsApi.updateAssets(
- AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId),
- );
- }
-
- if (childrenToRemove != null) {
- final toRemove = childrenToRemove
- .where((e) => e.isRemote)
- .map((e) => e.remoteId!)
- .toList();
- await _api.assetsApi.updateAssets(
- AssetBulkUpdateDto(ids: toRemove, removeParent: true),
- );
- }
- } catch (error) {
- debugPrint("Error while updating stack children: ${error.toString()}");
- }
- }
-
- Future updateStackParent(Asset oldParent, Asset newParent) async {
- // Guard [local asset]
- if (oldParent.remoteId == null || newParent.remoteId == null) {
- return;
- }
-
- try {
- await _api.assetsApi.updateStackParent(
- UpdateStackParentDto(
- oldParentId: oldParent.remoteId!,
- newParentId: newParent.remoteId!,
- ),
- );
- } catch (error) {
- debugPrint("Error while updating stack parent: ${error.toString()}");
- }
- }
-}
-
-final assetStackServiceProvider = Provider(
- (ref) => AssetStackService(
- ref.watch(apiServiceProvider),
- ),
-);
diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart
index ba8f5c01ed963..fc3feb174d582 100644
--- a/mobile/lib/services/background.service.dart
+++ b/mobile/lib/services/background.service.dart
@@ -10,6 +10,10 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/main.dart';
+import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
+import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
+import 'package:immich_mobile/services/album.service.dart';
+import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
@@ -18,6 +22,9 @@ import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/api.service.dart';
+import 'package:immich_mobile/services/partner.service.dart';
+import 'package:immich_mobile/services/sync.service.dart';
+import 'package:immich_mobile/services/user.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
@@ -342,11 +349,20 @@ class BackgroundService {
Future _onAssetsChanged() async {
final Isar db = await loadDb();
+ HttpOverrides.global = HttpSSLCertOverride();
ApiService apiService = ApiService();
apiService.setAccessToken(Store.get(StoreKey.accessToken));
AppSettingsService settingService = AppSettingsService();
- BackupService backupService = BackupService(apiService, db, settingService);
AppSettingsService settingsService = AppSettingsService();
+ PartnerService partnerService = PartnerService(apiService, db);
+ HashService hashService = HashService(db, this);
+ SyncService syncSerive = SyncService(db, hashService);
+ UserService userService =
+ UserService(apiService, db, syncSerive, partnerService);
+ AlbumService albumService =
+ AlbumService(apiService, userService, syncSerive, db);
+ BackupService backupService =
+ BackupService(apiService, db, settingService, albumService);
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
@@ -416,7 +432,7 @@ class BackgroundService {
return false;
}
- List toUpload = await backupService.buildUploadCandidates(
+ Set toUpload = await backupService.buildUploadCandidates(
selectedAlbums,
excludedAlbums,
);
@@ -460,29 +476,47 @@ class BackgroundService {
final bool ok = await backupService.backupAsset(
toUpload,
_cancellationToken!,
- pmProgressHandler,
- notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId, isDup) {},
- notifySingleProgress ? _onProgress : (sent, total) {},
- notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
- _onBackupError,
- sortAssets: true,
+ pmProgressHandler: pmProgressHandler,
+ onSuccess: (result) => _onAssetUploaded(
+ result: result,
+ shouldNotify: notifyTotalProgress,
+ ),
+ onProgress: (bytes, totalBytes) =>
+ _onProgress(bytes, totalBytes, shouldNotify: notifySingleProgress),
+ onCurrentAsset: (asset) =>
+ _onSetCurrentBackupAsset(asset, shouldNotify: notifySingleProgress),
+ onError: _onBackupError,
+ isBackground: true,
);
+
if (!ok && !_cancellationToken!.isCancelled) {
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
content: "backup_background_service_backup_failed_message".tr(),
);
}
+
return ok;
}
- void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
+ void _onAssetUploaded({
+ required SuccessUploadAsset result,
+ bool shouldNotify = false,
+ }) async {
+ if (!shouldNotify) {
+ return;
+ }
+
_uploadedAssetsCount++;
_throttledNotifiy();
}
- void _onProgress(int sent, int total) {
- _throttledDetailNotify(progress: sent, total: total);
+ void _onProgress(int bytes, int totalBytes, {bool shouldNotify = false}) {
+ if (!shouldNotify) {
+ return;
+ }
+
+ _throttledDetailNotify(progress: bytes, total: totalBytes);
}
void _updateDetailProgress(String? title, int progress, int total) {
@@ -522,7 +556,14 @@ class BackgroundService {
);
}
- void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
+ void _onSetCurrentBackupAsset(
+ CurrentUploadAsset currentUploadAsset, {
+ bool shouldNotify = false,
+ }) {
+ if (!shouldNotify) {
+ return;
+ }
+
_throttledDetailNotify.title =
"backup_background_service_current_upload_notification"
.tr(args: [currentUploadAsset.fileName]);
diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart
index 64d683dc2ae83..12edd14d609ca 100644
--- a/mobile/lib/services/backup.service.dart
+++ b/mobile/lib/services/backup.service.dart
@@ -9,11 +9,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
+import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
+import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
+import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:isar/isar.dart';
@@ -28,6 +31,7 @@ final backupServiceProvider = Provider(
ref.watch(apiServiceProvider),
ref.watch(dbProvider),
ref.watch(appSettingsServiceProvider),
+ ref.watch(albumServiceProvider),
),
);
@@ -37,8 +41,14 @@ class BackupService {
final Isar _db;
final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting;
+ final AlbumService _albumService;
- BackupService(this._apiService, this._db, this._appSetting);
+ BackupService(
+ this._apiService,
+ this._db,
+ this._appSetting,
+ this._albumService,
+ );
Future?> getDeviceBackupAsset() async {
final String deviceId = Store.get(StoreKey.deviceId);
@@ -70,10 +80,12 @@ class BackupService {
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
/// Returns all assets newer than the last successful backup per album
- Future> buildUploadCandidates(
+ /// if `useTimeFilter` is set to true, all assets will be returned
+ Future> buildUploadCandidates(
List selectedBackupAlbums,
- List excludedBackupAlbums,
- ) async {
+ List excludedBackupAlbums, {
+ bool useTimeFilter = true,
+ }) async {
final filter = FilterOptionGroup(
containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
@@ -82,105 +94,156 @@ class BackupService {
videoOption: const FilterOption(needTitle: true),
);
final now = DateTime.now();
+
final List selectedAlbums =
- await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now);
+ await _loadAlbumsWithTimeFilter(
+ selectedBackupAlbums,
+ filter,
+ now,
+ useTimeFilter: useTimeFilter,
+ );
+
if (selectedAlbums.every((e) => e == null)) {
- return [];
- }
- final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
- if (allIdx != -1) {
- final List excludedAlbums =
- await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
- final List toAdd = await _fetchAssetsAndUpdateLastBackup(
- selectedAlbums.slice(allIdx, allIdx + 1),
- selectedBackupAlbums.slice(allIdx, allIdx + 1),
- now,
- );
- final List toRemove = await _fetchAssetsAndUpdateLastBackup(
- excludedAlbums,
- excludedBackupAlbums,
- now,
- );
- return toAdd.toSet().difference(toRemove.toSet()).toList();
- } else {
- return await _fetchAssetsAndUpdateLastBackup(
- selectedAlbums,
- selectedBackupAlbums,
- now,
- );
+ return {};
}
+
+ final List excludedAlbums =
+ await _loadAlbumsWithTimeFilter(
+ excludedBackupAlbums,
+ filter,
+ now,
+ useTimeFilter: useTimeFilter,
+ );
+
+ final Set toAdd = await _fetchAssetsAndUpdateLastBackup(
+ selectedAlbums,
+ selectedBackupAlbums,
+ now,
+ useTimeFilter: useTimeFilter,
+ );
+
+ final Set toRemove = await _fetchAssetsAndUpdateLastBackup(
+ excludedAlbums,
+ excludedBackupAlbums,
+ now,
+ useTimeFilter: useTimeFilter,
+ );
+
+ return toAdd.difference(toRemove);
}
Future> _loadAlbumsWithTimeFilter(
List albums,
FilterOptionGroup filter,
- DateTime now,
- ) async {
+ DateTime now, {
+ bool useTimeFilter = true,
+ }) async {
List result = [];
- for (BackupAlbum a in albums) {
+ for (BackupAlbum backupAlbum in albums) {
try {
+ final optionGroup = useTimeFilter
+ ? filter.copyWith(
+ updateTimeCond: DateTimeCond(
+ // subtract 2 seconds to prevent missing assets due to rounding issues
+ min: backupAlbum.lastBackup
+ .subtract(const Duration(seconds: 2)),
+ max: now,
+ ),
+ )
+ : filter;
+
final AssetPathEntity album =
await AssetPathEntity.obtainPathFromProperties(
- id: a.id,
- optionGroup: filter.copyWith(
- updateTimeCond: DateTimeCond(
- // subtract 2 seconds to prevent missing assets due to rounding issues
- min: a.lastBackup.subtract(const Duration(seconds: 2)),
- max: now,
- ),
- ),
+ id: backupAlbum.id,
+ optionGroup: optionGroup,
maxDateTimeToNow: false,
);
+
result.add(album);
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
}
}
+
return result;
}
- Future> _fetchAssetsAndUpdateLastBackup(
- List albums,
+ Future> _fetchAssetsAndUpdateLastBackup(
+ List localAlbums,
List backupAlbums,
- DateTime now,
- ) async {
- List result = [];
- for (int i = 0; i < albums.length; i++) {
- final AssetPathEntity? a = albums[i];
- if (a != null &&
- a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) {
- result.addAll(
- await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
- );
- backupAlbums[i].lastBackup = now;
+ DateTime now, {
+ bool useTimeFilter = true,
+ }) async {
+ Set candidate = {};
+
+ for (int i = 0; i < localAlbums.length; i++) {
+ final localAlbum = localAlbums[i];
+ if (localAlbum == null) {
+ continue;
}
+
+ if (useTimeFilter &&
+ localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) ==
+ true) {
+ continue;
+ }
+
+ final assets = await localAlbum.getAssetListRange(
+ start: 0,
+ end: await localAlbum.assetCountAsync,
+ );
+
+ // Add album's name to the asset info
+ for (final asset in assets) {
+ List albumNames = [localAlbum.name];
+
+ final existingAsset = candidate.firstWhereOrNull(
+ (a) => a.asset.id == asset.id,
+ );
+
+ if (existingAsset != null) {
+ albumNames.addAll(existingAsset.albumNames);
+ candidate.remove(existingAsset);
+ }
+
+ candidate.add(
+ BackupCandidate(
+ asset: asset,
+ albumNames: albumNames,
+ ),
+ );
+ }
+
+ backupAlbums[i].lastBackup = now;
}
- return result;
+
+ return candidate;
}
/// Returns a new list of assets not yet uploaded
- Future> removeAlreadyUploadedAssets(
- List candidates,
+ Future> removeAlreadyUploadedAssets(
+ Set candidates,
) async {
if (candidates.isEmpty) {
return candidates;
}
+
final Set duplicatedAssetIds = await getDuplicatedAssetIds();
- candidates = duplicatedAssetIds.isEmpty
- ? candidates
- : candidates
- .whereNot((asset) => duplicatedAssetIds.contains(asset.id))
- .toList();
+ candidates.removeWhere(
+ (candidate) => duplicatedAssetIds.contains(candidate.asset.id),
+ );
+
if (candidates.isEmpty) {
return candidates;
}
+
final Set existing = {};
try {
final String deviceId = Store.get(StoreKey.deviceId);
final CheckExistingAssetsResponseDto? duplicates =
await _apiService.assetsApi.checkExistingAssets(
CheckExistingAssetsDto(
- deviceAssetIds: candidates.map((e) => e.id).toList(),
+ deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
deviceId: deviceId,
),
);
@@ -194,55 +257,75 @@ class BackupService {
existing.addAll(allAssetsInDatabase);
}
}
- return existing.isEmpty
- ? candidates
- : candidates.whereNot((e) => existing.contains(e.id)).toList();
+
+ if (existing.isNotEmpty) {
+ candidates.removeWhere((c) => existing.contains(c.asset.id));
+ }
+
+ return candidates;
}
- Future backupAsset(
- Iterable assetList,
- http.CancellationToken cancelToken,
- PMProgressHandler? pmProgressHandler,
- Function(String, String, bool) uploadSuccessCb,
- Function(int, int) uploadProgressCb,
- Function(CurrentUploadAsset) setCurrentUploadAssetCb,
- Function(ErrorUploadAsset) errorCb, {
- bool sortAssets = false,
- }) async {
- final bool isIgnoreIcloudAssets =
- _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
-
+ Future _checkPermissions() async {
if (Platform.isAndroid &&
!(await pm.Permission.accessMediaLocation.status).isGranted) {
// double check that permission is granted here, to guard against
// uploading corrupt assets without EXIF information
_log.warning("Media location permission is not granted. "
"Cannot access original assets for backup.");
+
return false;
}
- final String deviceId = Store.get(StoreKey.deviceId);
- final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
- bool anyErrors = false;
- final List duplicatedAssetIds = [];
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
if (Platform.isIOS) {
await PhotoManager.requestPermissionExtend();
}
- List assetsToUpload = sortAssets
- // Upload images before video assets
- // these are further sorted by using their creation date
- ? assetList.sorted(
- (a, b) {
- final cmp = a.typeInt - b.typeInt;
- if (cmp != 0) return cmp;
- return a.createDateTime.compareTo(b.createDateTime);
- },
- )
- : assetList.toList();
+ return true;
+ }
- for (var entity in assetsToUpload) {
+ /// Upload images before video assets for background tasks
+ /// these are further sorted by using their creation date
+ List _sortPhotosFirst(List candidates) {
+ return candidates.sorted(
+ (a, b) {
+ final cmp = a.asset.typeInt - b.asset.typeInt;
+ if (cmp != 0) return cmp;
+ return a.asset.createDateTime.compareTo(b.asset.createDateTime);
+ },
+ );
+ }
+
+ Future backupAsset(
+ Iterable assets,
+ http.CancellationToken cancelToken, {
+ bool isBackground = false,
+ PMProgressHandler? pmProgressHandler,
+ required void Function(SuccessUploadAsset result) onSuccess,
+ required void Function(int bytes, int totalBytes) onProgress,
+ required void Function(CurrentUploadAsset asset) onCurrentAsset,
+ required void Function(ErrorUploadAsset error) onError,
+ }) async {
+ final bool isIgnoreIcloudAssets =
+ _appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
+ final shouldSyncAlbums = _appSetting.getSetting(AppSettingsEnum.syncAlbums);
+ final String deviceId = Store.get(StoreKey.deviceId);
+ final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
+ final List duplicatedAssetIds = [];
+ bool anyErrors = false;
+
+ final hasPermission = await _checkPermissions();
+ if (!hasPermission) {
+ return false;
+ }
+
+ List candidates = assets.toList();
+ if (isBackground) {
+ candidates = _sortPhotosFirst(candidates);
+ }
+
+ for (final candidate in candidates) {
+ final AssetEntity entity = candidate.asset;
File? file;
File? livePhotoFile;
@@ -257,7 +340,7 @@ class BackupService {
continue;
}
- setCurrentUploadAssetCb(
+ onCurrentAsset(
CurrentUploadAsset(
id: entity.id,
fileCreatedAt: entity.createDateTime.year == 1970
@@ -299,23 +382,22 @@ class BackupService {
}
}
- var fileStream = file.openRead();
- var assetRawUploadData = http.MultipartFile(
+ final fileStream = file.openRead();
+ final assetRawUploadData = http.MultipartFile(
"assetData",
fileStream,
file.lengthSync(),
filename: originalFileName,
);
- var baseRequest = MultipartRequest(
+ final baseRequest = MultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),
- onProgress: ((bytes, totalBytes) =>
- uploadProgressCb(bytes, totalBytes)),
+ onProgress: ((bytes, totalBytes) => onProgress(bytes, totalBytes)),
);
+
baseRequest.headers.addAll(ApiService.getRequestHeaders());
baseRequest.headers["Transfer-Encoding"] = "chunked";
-
baseRequest.fields['deviceAssetId'] = entity.id;
baseRequest.fields['deviceId'] = deviceId;
baseRequest.fields['fileCreatedAt'] =
@@ -324,12 +406,9 @@ class BackupService {
entity.modifiedDateTime.toUtc().toIso8601String();
baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
baseRequest.fields['duration'] = entity.videoDuration.toString();
-
baseRequest.files.add(assetRawUploadData);
- var fileSize = file.lengthSync();
-
- setCurrentUploadAssetCb(
+ onCurrentAsset(
CurrentUploadAsset(
id: entity.id,
fileCreatedAt: entity.createDateTime.year == 1970
@@ -337,7 +416,7 @@ class BackupService {
: entity.createDateTime,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
- fileSize: fileSize,
+ fileSize: file.lengthSync(),
iCloudAsset: false,
),
);
@@ -356,22 +435,23 @@ class BackupService {
baseRequest.fields['livePhotoVideoId'] = livePhotoVideoId;
}
- var response = await httpClient.send(
+ final response = await httpClient.send(
baseRequest,
cancellationToken: cancelToken,
);
- var responseBody = jsonDecode(await response.stream.bytesToString());
+ final responseBody =
+ jsonDecode(await response.stream.bytesToString());
if (![200, 201].contains(response.statusCode)) {
- var error = responseBody;
- var errorMessage = error['message'] ?? error['error'];
+ final error = responseBody;
+ final errorMessage = error['message'] ?? error['error'];
debugPrint(
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
);
- errorCb(
+ onError(
ErrorUploadAsset(
asset: entity,
id: entity.id,
@@ -386,23 +466,37 @@ class BackupService {
anyErrors = true;
break;
}
+
continue;
}
- var isDuplicate = false;
+ bool isDuplicate = false;
if (response.statusCode == 200) {
isDuplicate = true;
duplicatedAssetIds.add(entity.id);
}
- uploadSuccessCb(entity.id, deviceId, isDuplicate);
+ onSuccess(
+ SuccessUploadAsset(
+ candidate: candidate,
+ remoteAssetId: responseBody['id'] as String,
+ isDuplicate: isDuplicate,
+ ),
+ );
+
+ if (shouldSyncAlbums && !isDuplicate) {
+ await _albumService.syncUploadAlbums(
+ candidate.albumNames,
+ [responseBody['id'] as String],
+ );
+ }
}
} on http.CancelledException {
debugPrint("Backup was cancelled by the user");
anyErrors = true;
break;
- } catch (e) {
- debugPrint("ERROR backupAsset: ${e.toString()}");
+ } catch (error, stackTrace) {
+ debugPrint("Error backup asset: ${error.toString()}: $stackTrace");
anyErrors = true;
continue;
} finally {
@@ -416,9 +510,11 @@ class BackupService {
}
}
}
+
if (duplicatedAssetIds.isNotEmpty) {
await _saveDuplicatedAssetIds(duplicatedAssetIds);
}
+
return !anyErrors;
}
diff --git a/mobile/lib/services/oauth.service.dart b/mobile/lib/services/oauth.service.dart
index 807c88db8de50..30e6448d7f8a9 100644
--- a/mobile/lib/services/oauth.service.dart
+++ b/mobile/lib/services/oauth.service.dart
@@ -3,7 +3,7 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:flutter_web_auth/flutter_web_auth.dart';
-// Redirect URL = app.immich://
+// Redirect URL = app.immich:///oauth-callback
class OAuthService {
final ApiService _apiService;
@@ -16,28 +16,40 @@ class OAuthService {
) async {
// Resolve API server endpoint from user provided serverUrl
await _apiService.resolveAndSetEndpoint(serverUrl);
+ final redirectUri = '$callbackUrlScheme:///oauth-callback';
+ log.info(
+ "Starting OAuth flow with redirect URI: $redirectUri",
+ );
final dto = await _apiService.oAuthApi.startOAuth(
- OAuthConfigDto(redirectUri: '$callbackUrlScheme:/'),
+ OAuthConfigDto(redirectUri: redirectUri),
);
- return dto?.url;
+
+ final authUrl = dto?.url;
+ log.info('Received Authorization URL: $authUrl');
+
+ return authUrl;
}
Future oAuthLogin(String oauthUrl) async {
- try {
- var result = await FlutterWebAuth.authenticate(
- url: oauthUrl,
- callbackUrlScheme: callbackUrlScheme,
- );
+ String result = await FlutterWebAuth.authenticate(
+ url: oauthUrl,
+ callbackUrlScheme: callbackUrlScheme,
+ );
- return await _apiService.oAuthApi.finishOAuth(
- OAuthCallbackDto(
- url: result,
- ),
+ log.info('Received OAuth callback: $result');
+
+ if (result.startsWith('app.immich:/oauth-callback')) {
+ result = result.replaceAll(
+ 'app.immich:/oauth-callback',
+ 'app.immich:///oauth-callback',
);
- } catch (e, stack) {
- log.severe("OAuth login failed", e, stack);
- return null;
}
+
+ return await _apiService.oAuthApi.finishOAuth(
+ OAuthCallbackDto(
+ url: result,
+ ),
+ );
}
}
diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart
new file mode 100644
index 0000000000000..75074101c2ff8
--- /dev/null
+++ b/mobile/lib/services/stack.service.dart
@@ -0,0 +1,79 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/entities/asset.entity.dart';
+import 'package:immich_mobile/providers/api.provider.dart';
+import 'package:immich_mobile/providers/db.provider.dart';
+import 'package:immich_mobile/services/api.service.dart';
+import 'package:isar/isar.dart';
+import 'package:openapi/api.dart';
+
+class StackService {
+ StackService(this._api, this._db);
+
+ final ApiService _api;
+ final Isar _db;
+
+ Future getStack(String stackId) async {
+ try {
+ return _api.stacksApi.getStack(stackId);
+ } catch (error) {
+ debugPrint("Error while fetching stack: $error");
+ }
+ return null;
+ }
+
+ Future createStack(List assetIds) async {
+ try {
+ return _api.stacksApi.createStack(
+ StackCreateDto(assetIds: assetIds),
+ );
+ } catch (error) {
+ debugPrint("Error while creating stack: $error");
+ }
+ return null;
+ }
+
+ Future updateStack(
+ String stackId,
+ String primaryAssetId,
+ ) async {
+ try {
+ return await _api.stacksApi.updateStack(
+ stackId,
+ StackUpdateDto(primaryAssetId: primaryAssetId),
+ );
+ } catch (error) {
+ debugPrint("Error while updating stack children: $error");
+ }
+ return null;
+ }
+
+ Future deleteStack(String stackId, List assets) async {
+ try {
+ await _api.stacksApi.deleteStack(stackId);
+
+ // Update local database to trigger rerendering
+ final List removeAssets = [];
+ for (final asset in assets) {
+ asset.stackId = null;
+ asset.stackPrimaryAssetId = null;
+ asset.stackCount = 0;
+
+ removeAssets.add(asset);
+ }
+
+ _db.writeTxn(() async {
+ await _db.assets.putAll(removeAssets);
+ });
+ } catch (error) {
+ debugPrint("Error while deleting stack: $error");
+ }
+ }
+}
+
+final stackServiceProvider = Provider(
+ (ref) => StackService(
+ ref.watch(apiServiceProvider),
+ ref.watch(dbProvider),
+ ),
+);
diff --git a/mobile/lib/utils/hooks/crop_controller_hook.dart b/mobile/lib/utils/hooks/crop_controller_hook.dart
index b03d9ccdb0917..04bc9787548eb 100644
--- a/mobile/lib/utils/hooks/crop_controller_hook.dart
+++ b/mobile/lib/utils/hooks/crop_controller_hook.dart
@@ -6,7 +6,7 @@ import 'dart:ui'; // Import the dart:ui library for Rect
CropController useCropController() {
return useMemoized(
() => CropController(
- defaultCrop: const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9),
+ defaultCrop: const Rect.fromLTRB(0, 0, 1, 1),
),
);
}
diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart
index 7b27f59aee8c3..349b2322afac1 100644
--- a/mobile/lib/utils/openapi_patching.dart
+++ b/mobile/lib/utils/openapi_patching.dart
@@ -4,9 +4,30 @@ dynamic upgradeDto(dynamic value, String targetType) {
switch (targetType) {
case 'UserPreferencesResponseDto':
if (value is Map) {
- if (value['rating'] == null) {
- value['rating'] = RatingResponse().toJson();
- }
+ addDefault(value, 'download.includeEmbeddedVideos', false);
+ addDefault(value, 'folders', FoldersResponse().toJson());
+ addDefault(value, 'memories', MemoriesResponse().toJson());
+ addDefault(value, 'ratings', RatingsResponse().toJson());
+ addDefault(value, 'people', PeopleResponse().toJson());
+ addDefault(value, 'tags', TagsResponse().toJson());
}
+ break;
+ }
+}
+
+addDefault(dynamic value, String keys, dynamic defaultValue) {
+ // Loop through the keys and assign the default value if the key is not present
+ List keyList = keys.split('.');
+ dynamic current = value;
+
+ for (int i = 0; i < keyList.length - 1; i++) {
+ if (current[keyList[i]] == null) {
+ current[keyList[i]] = {};
+ }
+ current = current[keyList[i]];
+ }
+
+ if (current[keyList.last] == null) {
+ current[keyList.last] = defaultValue;
}
}
diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart
index e50a9a5ece6e3..3263373554df2 100644
--- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart
+++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart
@@ -11,7 +11,7 @@ import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/services/album.service.dart';
-import 'package:immich_mobile/services/asset_stack.service.dart';
+import 'package:immich_mobile/services/stack.service.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/models/asset_selection_state.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
@@ -344,11 +344,9 @@ class MultiselectGrid extends HookConsumerWidget {
if (!selectionEnabledHook.value || selection.value.length < 2) {
return;
}
- final parent = selection.value.elementAt(0);
- selection.value.remove(parent);
- await ref.read(assetStackServiceProvider).updateStack(
- parent,
- childrenToAdd: selection.value.toList(),
+
+ await ref.read(stackServiceProvider).createStack(
+ selection.value.map((e) => e.remoteId!).toList(),
);
} finally {
processing.value = false;
diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart
index 2480f44278bb1..8e818f64fb7cc 100644
--- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart
+++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart
@@ -107,16 +107,16 @@ class ThumbnailImage extends ConsumerWidget {
right: 8,
child: Row(
children: [
- if (asset.stackChildrenCount > 1)
+ if (asset.stackCount > 1)
Text(
- "${asset.stackChildrenCount}",
+ "${asset.stackCount}",
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
- if (asset.stackChildrenCount > 1)
+ if (asset.stackCount > 1)
const SizedBox(
width: 3,
),
@@ -208,7 +208,7 @@ class ThumbnailImage extends ConsumerWidget {
),
),
if (!asset.isImage) buildVideoIcon(),
- if (asset.stackChildrenCount > 0) buildStackIcon(),
+ if (asset.stackCount > 0) buildStackIcon(),
],
);
}
diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
index fb70ac309ed7f..7e6136c256192 100644
--- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
+++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
@@ -11,11 +11,12 @@ import 'package:immich_mobile/providers/album/shared_album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
-import 'package:immich_mobile/services/asset_stack.service.dart';
+import 'package:immich_mobile/services/stack.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -49,11 +50,10 @@ class BottomGalleryBar extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
- final stack = showStack && asset.stackChildrenCount > 0
+ final stackItems = showStack && asset.stackCount > 0
? ref.watch(assetStackStateProvider(asset))
: [];
- final stackElements = showStack ? [asset, ...stack] : [];
- bool isParent = stackIndex == -1 || stackIndex == 0;
+ bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
final navStack = AutoRouter.of(context).stackData;
final isTrashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
@@ -76,7 +76,7 @@ class BottomGalleryBar extends ConsumerWidget {
{asset},
force: force,
);
- if (isDeleted && isParent) {
+ if (isDeleted && isStackPrimaryAsset) {
// Workaround for asset remaining in the gallery
renderList.deleteAsset(asset);
@@ -98,7 +98,7 @@ class BottomGalleryBar extends ConsumerWidget {
final isDeleted = await onDelete(false);
if (isDeleted) {
// Can only trash assets stored in server. Local assets are always permanently removed for now
- if (context.mounted && asset.isRemote && isParent) {
+ if (context.mounted && asset.isRemote && isStackPrimaryAsset) {
ImmichToast.show(
durationInSecond: 1,
context: context,
@@ -127,6 +127,16 @@ class BottomGalleryBar extends ConsumerWidget {
);
}
+ unStack() async {
+ if (asset.stackId == null) {
+ return;
+ }
+
+ await ref
+ .read(stackServiceProvider)
+ .deleteStack(asset.stackId!, [asset, ...stackItems]);
+ }
+
void showStackActionItems() {
showModalBottomSheet(
context: context,
@@ -138,74 +148,13 @@ class BottomGalleryBar extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- if (!isParent)
- ListTile(
- leading: const Icon(
- Icons.bookmark_border_outlined,
- size: 24,
- ),
- onTap: () async {
- await ref
- .read(assetStackServiceProvider)
- .updateStackParent(
- asset,
- stackElements.elementAt(stackIndex),
- );
- ctx.pop();
- context.maybePop();
- },
- title: const Text(
- "viewer_stack_use_as_main_asset",
- style: TextStyle(fontWeight: FontWeight.bold),
- ).tr(),
- ),
- ListTile(
- leading: const Icon(
- Icons.copy_all_outlined,
- size: 24,
- ),
- onTap: () async {
- if (isParent) {
- await ref
- .read(assetStackServiceProvider)
- .updateStackParent(
- asset,
- stackElements
- .elementAt(1), // Next asset as parent
- );
- // Remove itself from stack
- await ref.read(assetStackServiceProvider).updateStack(
- stackElements.elementAt(1),
- childrenToRemove: [asset],
- );
- ctx.pop();
- context.maybePop();
- } else {
- await ref.read(assetStackServiceProvider).updateStack(
- asset,
- childrenToRemove: [
- stackElements.elementAt(stackIndex),
- ],
- );
- removeAssetFromStack();
- ctx.pop();
- }
- },
- title: const Text(
- "viewer_remove_from_stack",
- style: TextStyle(fontWeight: FontWeight.bold),
- ).tr(),
- ),
ListTile(
leading: const Icon(
Icons.filter_none_outlined,
size: 18,
),
onTap: () async {
- await ref.read(assetStackServiceProvider).updateStack(
- asset,
- childrenToRemove: stack,
- );
+ await unStack();
ctx.pop();
context.maybePop();
},
@@ -236,6 +185,7 @@ class BottomGalleryBar extends ConsumerWidget {
}
void handleEdit() async {
+ final image = Image(image: ImmichImage.imageProvider(asset: asset));
if (asset.isOffline) {
ImmichToast.show(
durationInSecond: 1,
@@ -247,15 +197,18 @@ class BottomGalleryBar extends ConsumerWidget {
}
Navigator.of(context).push(
MaterialPageRoute(
- builder: (context) =>
- EditImagePage(asset: asset), // Send the Asset object
+ builder: (context) => EditImagePage(
+ asset: asset,
+ image: image,
+ isEdited: false,
+ ),
),
);
}
handleArchive() {
ref.read(assetProvider.notifier).toggleArchive([asset]);
- if (isParent) {
+ if (isStackPrimaryAsset) {
context.maybePop();
return;
}
@@ -346,7 +299,7 @@ class BottomGalleryBar extends ConsumerWidget {
tooltip: 'control_bottom_app_bar_archive'.tr(),
): (_) => handleArchive(),
},
- if (isOwner && stack.isNotEmpty)
+ if (isOwner && asset.stackCount > 0)
{
BottomNavigationBarItem(
icon: const Icon(Icons.burst_mode_outlined),
diff --git a/mobile/lib/widgets/backup/album_info_card.dart b/mobile/lib/widgets/backup/album_info_card.dart
index e9349bd69eccf..0c9cd2d89d33c 100644
--- a/mobile/lib/widgets/backup/album_info_card.dart
+++ b/mobile/lib/widgets/backup/album_info_card.dart
@@ -5,15 +5,21 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
+import 'package:immich_mobile/providers/album/album.provider.dart';
+import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
+import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class AlbumInfoCard extends HookConsumerWidget {
final AvailableAlbum album;
- const AlbumInfoCard({super.key, required this.album});
+ const AlbumInfoCard({
+ super.key,
+ required this.album,
+ });
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -21,6 +27,9 @@ class AlbumInfoCard extends HookConsumerWidget {
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
+ final syncAlbum = ref
+ .watch(appSettingsServiceProvider)
+ .getSetting(AppSettingsEnum.syncAlbums);
final isDarkTheme = context.isDarkTheme;
@@ -85,6 +94,9 @@ class AlbumInfoCard extends HookConsumerWidget {
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
} else {
ref.read(backupProvider.notifier).addAlbumForBackup(album);
+ if (syncAlbum) {
+ ref.read(albumProvider.notifier).createSyncAlbum(album.name);
+ }
}
},
onDoubleTap: () {
diff --git a/mobile/lib/widgets/backup/album_info_list_tile.dart b/mobile/lib/widgets/backup/album_info_list_tile.dart
index 7cdc595c7fc53..d326bad3e0fc7 100644
--- a/mobile/lib/widgets/backup/album_info_list_tile.dart
+++ b/mobile/lib/widgets/backup/album_info_list_tile.dart
@@ -5,9 +5,12 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
+import 'package:immich_mobile/providers/album/album.provider.dart';
+import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
+import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class AlbumInfoListTile extends HookConsumerWidget {
@@ -21,7 +24,10 @@ class AlbumInfoListTile extends HookConsumerWidget {
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
- var assetCount = useState(0);
+ final assetCount = useState(0);
+ final syncAlbum = ref
+ .watch(appSettingsServiceProvider)
+ .getSetting(AppSettingsEnum.syncAlbums);
useEffect(
() {
@@ -98,6 +104,9 @@ class AlbumInfoListTile extends HookConsumerWidget {
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
} else {
ref.read(backupProvider.notifier).addAlbumForBackup(album);
+ if (syncAlbum) {
+ ref.read(albumProvider.notifier).createSyncAlbum(album.name);
+ }
}
},
leading: buildIcon(),
diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart
index 4384879fce6a0..14a4e89dd6066 100644
--- a/mobile/lib/widgets/forms/login/login_form.dart
+++ b/mobile/lib/widgets/forms/login/login_form.dart
@@ -27,12 +27,15 @@ import 'package:immich_mobile/widgets/forms/login/login_button.dart';
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
+import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
class LoginForm extends HookConsumerWidget {
- const LoginForm({super.key});
+ LoginForm({super.key});
+
+ final log = Logger('LoginForm');
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -229,7 +232,9 @@ class LoginForm extends HookConsumerWidget {
.getOAuthServerUrl(sanitizeUrl(serverEndpointController.text));
isLoading.value = true;
- } catch (e) {
+ } catch (error, stack) {
+ log.severe('Error getting OAuth server Url: $error', stack);
+
ImmichToast.show(
context: context,
msg: "login_form_failed_get_oauth_server_config".tr(),
@@ -241,10 +246,19 @@ class LoginForm extends HookConsumerWidget {
}
if (oAuthServerUrl != null) {
- var loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl);
+ try {
+ final loginResponseDto =
+ await oAuthService.oAuthLogin(oAuthServerUrl);
- if (loginResponseDto != null) {
- var isSuccess = await ref
+ if (loginResponseDto == null) {
+ return;
+ }
+
+ log.info(
+ "Finished OAuth login with response: ${loginResponseDto.userEmail}",
+ );
+
+ final isSuccess = await ref
.watch(authenticationProvider.notifier)
.setSuccessLoginInfo(
accessToken: loginResponseDto.accessToken,
@@ -258,17 +272,19 @@ class LoginForm extends HookConsumerWidget {
ref.watch(backupProvider.notifier).resumeBackup();
}
context.replaceRoute(const TabControllerRoute());
- } else {
- ImmichToast.show(
- context: context,
- msg: "login_form_failed_login".tr(),
- toastType: ToastType.error,
- gravity: ToastGravity.TOP,
- );
}
- }
+ } catch (error, stack) {
+ log.severe('Error logging in with OAuth: $error', stack);
- isLoading.value = false;
+ ImmichToast.show(
+ context: context,
+ msg: error.toString(),
+ toastType: ToastType.error,
+ gravity: ToastGravity.TOP,
+ );
+ } finally {
+ isLoading.value = false;
+ }
} else {
ImmichToast.show(
context: context,
diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart
index 4d4fa8c4e0d54..41e9cc628e71a 100644
--- a/mobile/lib/widgets/memories/memory_lane.dart
+++ b/mobile/lib/widgets/memories/memory_lane.dart
@@ -1,6 +1,8 @@
import 'package:auto_route/auto_route.dart';
+import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -9,6 +11,7 @@ import 'package:immich_mobile/widgets/common/immich_image.dart';
class MemoryLane extends HookConsumerWidget {
const MemoryLane({super.key});
+
@override
Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneFutureProvider = ref.watch(memoryFutureProvider);
@@ -16,82 +19,35 @@ class MemoryLane extends HookConsumerWidget {
final memoryLane = memoryLaneFutureProvider
.whenData(
(memories) => memories != null
- ? SizedBox(
- height: 200,
- child: ListView.builder(
- scrollDirection: Axis.horizontal,
- shrinkWrap: true,
- itemCount: memories.length,
- padding: const EdgeInsets.only(
- right: 8.0,
- bottom: 8,
- top: 10,
- left: 10,
+ ? ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxHeight: 200,
+ ),
+ child: CarouselView(
+ itemExtent: 145.0,
+ shrinkExtent: 1.0,
+ elevation: 2,
+ backgroundColor: Colors.black,
+ overlayColor: WidgetStateProperty.all(
+ Colors.white.withOpacity(0.1),
),
- itemBuilder: (context, index) {
- final memory = memories[index];
-
- return GestureDetector(
- onTap: () {
- ref
- .read(hapticFeedbackProvider.notifier)
- .heavyImpact();
- context.pushRoute(
- MemoryRoute(
- memories: memories,
- memoryIndex: index,
- ),
- );
- },
- child: Stack(
- children: [
- Card(
- elevation: 3,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(13.0),
- ),
- clipBehavior: Clip.hardEdge,
- child: ColorFiltered(
- colorFilter: ColorFilter.mode(
- Colors.black.withOpacity(0.2),
- BlendMode.darken,
- ),
- child: Hero(
- tag: 'memory-${memory.assets[0].id}',
- child: ImmichImage(
- memory.assets[0],
- fit: BoxFit.cover,
- width: 130,
- height: 200,
- placeholder: const ThumbnailPlaceholder(
- width: 130,
- height: 200,
- ),
- ),
- ),
- ),
- ),
- Positioned(
- bottom: 16,
- left: 16,
- child: ConstrainedBox(
- constraints: const BoxConstraints(
- maxWidth: 114,
- ),
- child: Text(
- memory.title,
- style: const TextStyle(
- fontWeight: FontWeight.w600,
- color: Colors.white,
- fontSize: 15,
- ),
- ),
- ),
- ),
- ],
+ onTap: (memoryIndex) {
+ ref.read(hapticFeedbackProvider.notifier).heavyImpact();
+ context.pushRoute(
+ MemoryRoute(
+ memories: memories,
+ memoryIndex: memoryIndex,
),
);
},
+ children: memories
+ .mapIndexed(
+ (index, memory) => MemoryCard(
+ index: index,
+ memory: memory,
+ ),
+ )
+ .toList(),
),
)
: const SizedBox(),
@@ -101,3 +57,60 @@ class MemoryLane extends HookConsumerWidget {
return memoryLane ?? const SizedBox();
}
}
+
+class MemoryCard extends ConsumerWidget {
+ const MemoryCard({
+ super.key,
+ required this.index,
+ required this.memory,
+ });
+
+ final int index;
+ final Memory memory;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return Center(
+ child: Stack(
+ children: [
+ ColorFiltered(
+ colorFilter: ColorFilter.mode(
+ Colors.black.withOpacity(0.2),
+ BlendMode.darken,
+ ),
+ child: Hero(
+ tag: 'memory-${memory.assets[0].id}',
+ child: ImmichImage(
+ memory.assets[0],
+ fit: BoxFit.cover,
+ width: 205,
+ height: 200,
+ placeholder: const ThumbnailPlaceholder(
+ width: 105,
+ height: 200,
+ ),
+ ),
+ ),
+ ),
+ Positioned(
+ bottom: 16,
+ left: 16,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxWidth: 114,
+ ),
+ child: Text(
+ memory.title,
+ style: const TextStyle(
+ fontWeight: FontWeight.w600,
+ color: Colors.white,
+ fontSize: 15,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/widgets/search/search_filter/camera_picker.dart b/mobile/lib/widgets/search/search_filter/camera_picker.dart
index 2e5618c9e03bf..e2110c9c295f1 100644
--- a/mobile/lib/widgets/search/search_filter/camera_picker.dart
+++ b/mobile/lib/widgets/search/search_filter/camera_picker.dart
@@ -51,10 +51,14 @@ class CameraPicker extends HookConsumerWidget {
controller: makeTextController,
leadingIcon: const Icon(Icons.photo_camera_rounded),
onSelected: (value) {
+ if (value.toString() == selectedMake.value) {
+ return;
+ }
selectedMake.value = value.toString();
+ modelTextController.value = TextEditingValue.empty;
onSelect({
'make': selectedMake.value,
- 'model': selectedModel.value,
+ 'model': null,
});
},
);
diff --git a/mobile/lib/widgets/search/search_filter/common/dropdown.dart b/mobile/lib/widgets/search/search_filter/common/dropdown.dart
index 230d7dd4daa5d..dd8785459f7fb 100644
--- a/mobile/lib/widgets/search/search_filter/common/dropdown.dart
+++ b/mobile/lib/widgets/search/search_filter/common/dropdown.dart
@@ -29,6 +29,7 @@ class SearchDropdown extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
return DropdownMenu(
+ controller: controller,
leadingIcon: leadingIcon,
width: constraints.maxWidth,
dropdownMenuEntries: dropdownMenuEntries,
diff --git a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart
index 25bcf2d06e507..c093e8f1e3c98 100644
--- a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart
+++ b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart
@@ -1,9 +1,12 @@
import 'dart:io';
+import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/backup/backup_verification.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
+import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/background_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/foreground_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
@@ -23,7 +26,21 @@ class BackupSettings extends HookConsumerWidget {
useAppSettingsState(AppSettingsEnum.ignoreIcloudAssets);
final isAdvancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
+ final albumSync = useAppSettingsState(AppSettingsEnum.syncAlbums);
final isCorruptCheckInProgress = ref.watch(backupVerificationProvider);
+ final isAlbumSyncInProgress = useState(false);
+
+ syncAlbums() async {
+ isAlbumSyncInProgress.value = true;
+ try {
+ await ref.read(assetServiceProvider).syncUploadedAssetToAlbums();
+ } catch (_) {
+ } finally {
+ Future.delayed(const Duration(seconds: 1), () {
+ isAlbumSyncInProgress.value = false;
+ });
+ }
+ }
final backupSettings = [
const ForegroundBackupSettings(),
@@ -58,6 +75,23 @@ class BackupSettings extends HookConsumerWidget {
.performBackupCheck(context)
: null,
),
+ if (albumSync.value)
+ SettingsButtonListTile(
+ icon: Icons.photo_album_outlined,
+ title: 'sync_albums'.tr(),
+ subtitle: Text(
+ "sync_albums_manual_subtitle".tr(),
+ ),
+ buttonText: 'sync_albums'.tr(),
+ child: isAlbumSyncInProgress.value
+ ? const CircularProgressIndicator.adaptive(
+ strokeWidth: 2,
+ )
+ : ElevatedButton(
+ onPressed: syncAlbums,
+ child: Text('sync'.tr()),
+ ),
+ ),
];
return SettingsSubPageScaffold(
diff --git a/mobile/lib/widgets/settings/settings_button_list_tile.dart b/mobile/lib/widgets/settings/settings_button_list_tile.dart
index 196e3d170feaf..c8bd8e4b588c9 100644
--- a/mobile/lib/widgets/settings/settings_button_list_tile.dart
+++ b/mobile/lib/widgets/settings/settings_button_list_tile.dart
@@ -9,6 +9,7 @@ class SettingsButtonListTile extends StatelessWidget {
final Widget? subtitle;
final String? subtileText;
final String buttonText;
+ final Widget? child;
final void Function()? onButtonTap;
const SettingsButtonListTile({
@@ -18,6 +19,7 @@ class SettingsButtonListTile extends StatelessWidget {
this.subtileText,
this.subtitle,
required this.buttonText,
+ this.child,
this.onButtonTap,
super.key,
});
@@ -48,7 +50,8 @@ class SettingsButtonListTile extends StatelessWidget {
),
if (subtitle != null) subtitle!,
const SizedBox(height: 6),
- ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)),
+ child ??
+ ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)),
],
),
);
diff --git a/mobile/lib/widgets/settings/settings_switch_list_tile.dart b/mobile/lib/widgets/settings/settings_switch_list_tile.dart
index 78f1738266a31..8aa4ec0a60ec0 100644
--- a/mobile/lib/widgets/settings/settings_switch_list_tile.dart
+++ b/mobile/lib/widgets/settings/settings_switch_list_tile.dart
@@ -9,6 +9,9 @@ class SettingsSwitchListTile extends StatelessWidget {
final String? subtitle;
final IconData? icon;
final Function(bool)? onChanged;
+ final EdgeInsets? contentPadding;
+ final TextStyle? titleStyle;
+ final TextStyle? subtitleStyle;
const SettingsSwitchListTile({
required this.valueNotifier,
@@ -17,6 +20,9 @@ class SettingsSwitchListTile extends StatelessWidget {
this.icon,
this.enabled = true,
this.onChanged,
+ this.contentPadding = const EdgeInsets.symmetric(horizontal: 20),
+ this.titleStyle,
+ this.subtitleStyle,
super.key,
});
@@ -30,7 +36,7 @@ class SettingsSwitchListTile extends StatelessWidget {
}
return SwitchListTile.adaptive(
- contentPadding: const EdgeInsets.symmetric(horizontal: 20),
+ contentPadding: contentPadding,
selectedTileColor: enabled ? null : context.themeData.disabledColor,
value: valueNotifier.value,
onChanged: onSwitchChanged,
@@ -45,20 +51,22 @@ class SettingsSwitchListTile extends StatelessWidget {
: null,
title: Text(
title,
- style: context.textTheme.bodyLarge?.copyWith(
- fontWeight: FontWeight.w500,
- color: enabled ? null : context.themeData.disabledColor,
- height: 1.5,
- ),
+ style: titleStyle ??
+ context.textTheme.bodyLarge?.copyWith(
+ fontWeight: FontWeight.w500,
+ color: enabled ? null : context.themeData.disabledColor,
+ height: 1.5,
+ ),
),
subtitle: subtitle != null
? Text(
subtitle!,
- style: context.textTheme.bodyMedium?.copyWith(
- color: enabled
- ? context.colorScheme.onSurfaceSecondary
- : context.themeData.disabledColor,
- ),
+ style: subtitleStyle ??
+ context.textTheme.bodyMedium?.copyWith(
+ color: enabled
+ ? context.colorScheme.onSurfaceSecondary
+ : context.themeData.disabledColor,
+ ),
)
: null,
);
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 657dad9d5b33b..66707f917594e 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
-- API version: 1.112.1
+- API version: 1.113.0
- Generator version: 7.5.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -86,8 +86,8 @@ Class | Method | HTTP request | Description
*AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users |
*AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums |
*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} |
-*AlbumsApi* | [**getAlbumCount**](doc//AlbumsApi.md#getalbumcount) | **GET** /albums/count |
*AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} |
+*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics |
*AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums |
*AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets |
*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} |
@@ -107,7 +107,6 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs |
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} |
*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets |
-*AssetsApi* | [**updateStackParent**](doc//AssetsApi.md#updatestackparent) | **PUT** /assets/stack/parent |
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
*AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
@@ -116,16 +115,7 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
-*DeprecatedApi* | [**getAboutInfo**](doc//DeprecatedApi.md#getaboutinfo) | **GET** /server-info/about |
*DeprecatedApi* | [**getPersonAssets**](doc//DeprecatedApi.md#getpersonassets) | **GET** /people/{id}/assets |
-*DeprecatedApi* | [**getServerConfig**](doc//DeprecatedApi.md#getserverconfig) | **GET** /server-info/config |
-*DeprecatedApi* | [**getServerFeatures**](doc//DeprecatedApi.md#getserverfeatures) | **GET** /server-info/features |
-*DeprecatedApi* | [**getServerStatistics**](doc//DeprecatedApi.md#getserverstatistics) | **GET** /server-info/statistics |
-*DeprecatedApi* | [**getServerVersion**](doc//DeprecatedApi.md#getserverversion) | **GET** /server-info/version |
-*DeprecatedApi* | [**getStorage**](doc//DeprecatedApi.md#getstorage) | **GET** /server-info/storage |
-*DeprecatedApi* | [**getSupportedMediaTypes**](doc//DeprecatedApi.md#getsupportedmediatypes) | **GET** /server-info/media-types |
-*DeprecatedApi* | [**getTheme**](doc//DeprecatedApi.md#gettheme) | **GET** /server-info/theme |
-*DeprecatedApi* | [**pingServer**](doc//DeprecatedApi.md#pingserver) | **GET** /server-info/ping |
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info |
*DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates |
@@ -183,17 +173,17 @@ Class | Method | HTTP request | Description
*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
*ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license |
+*ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about |
+*ServerApi* | [**getServerConfig**](doc//ServerApi.md#getserverconfig) | **GET** /server/config |
+*ServerApi* | [**getServerFeatures**](doc//ServerApi.md#getserverfeatures) | **GET** /server/features |
*ServerApi* | [**getServerLicense**](doc//ServerApi.md#getserverlicense) | **GET** /server/license |
+*ServerApi* | [**getServerStatistics**](doc//ServerApi.md#getserverstatistics) | **GET** /server/statistics |
+*ServerApi* | [**getServerVersion**](doc//ServerApi.md#getserverversion) | **GET** /server/version |
+*ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage |
+*ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types |
+*ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme |
+*ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping |
*ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license |
-*ServerInfoApi* | [**getAboutInfo**](doc//ServerInfoApi.md#getaboutinfo) | **GET** /server-info/about |
-*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config |
-*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
-*ServerInfoApi* | [**getServerStatistics**](doc//ServerInfoApi.md#getserverstatistics) | **GET** /server-info/statistics |
-*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
-*ServerInfoApi* | [**getStorage**](doc//ServerInfoApi.md#getstorage) | **GET** /server-info/storage |
-*ServerInfoApi* | [**getSupportedMediaTypes**](doc//ServerInfoApi.md#getsupportedmediatypes) | **GET** /server-info/media-types |
-*ServerInfoApi* | [**getTheme**](doc//ServerInfoApi.md#gettheme) | **GET** /server-info/theme |
-*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
*SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions |
*SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} |
*SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions |
@@ -205,6 +195,12 @@ Class | Method | HTTP request | Description
*SharedLinksApi* | [**removeSharedLink**](doc//SharedLinksApi.md#removesharedlink) | **DELETE** /shared-links/{id} |
*SharedLinksApi* | [**removeSharedLinkAssets**](doc//SharedLinksApi.md#removesharedlinkassets) | **DELETE** /shared-links/{id}/assets |
*SharedLinksApi* | [**updateSharedLink**](doc//SharedLinksApi.md#updatesharedlink) | **PATCH** /shared-links/{id} |
+*StacksApi* | [**createStack**](doc//StacksApi.md#createstack) | **POST** /stacks |
+*StacksApi* | [**deleteStack**](doc//StacksApi.md#deletestack) | **DELETE** /stacks/{id} |
+*StacksApi* | [**deleteStacks**](doc//StacksApi.md#deletestacks) | **DELETE** /stacks |
+*StacksApi* | [**getStack**](doc//StacksApi.md#getstack) | **GET** /stacks/{id} |
+*StacksApi* | [**searchStacks**](doc//StacksApi.md#searchstacks) | **GET** /stacks |
+*StacksApi* | [**updateStack**](doc//StacksApi.md#updatestack) | **PUT** /stacks/{id} |
*SyncApi* | [**getDeltaSync**](doc//SyncApi.md#getdeltasync) | **POST** /sync/delta-sync |
*SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync |
*SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config |
@@ -214,14 +210,15 @@ Class | Method | HTTP request | Description
*SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding |
*SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state |
*SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding |
+*TagsApi* | [**bulkTagAssets**](doc//TagsApi.md#bulktagassets) | **PUT** /tags/assets |
*TagsApi* | [**createTag**](doc//TagsApi.md#createtag) | **POST** /tags |
*TagsApi* | [**deleteTag**](doc//TagsApi.md#deletetag) | **DELETE** /tags/{id} |
*TagsApi* | [**getAllTags**](doc//TagsApi.md#getalltags) | **GET** /tags |
-*TagsApi* | [**getTagAssets**](doc//TagsApi.md#gettagassets) | **GET** /tags/{id}/assets |
*TagsApi* | [**getTagById**](doc//TagsApi.md#gettagbyid) | **GET** /tags/{id} |
*TagsApi* | [**tagAssets**](doc//TagsApi.md#tagassets) | **PUT** /tags/{id}/assets |
*TagsApi* | [**untagAssets**](doc//TagsApi.md#untagassets) | **DELETE** /tags/{id}/assets |
-*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PATCH** /tags/{id} |
+*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PUT** /tags/{id} |
+*TagsApi* | [**upsertTags**](doc//TagsApi.md#upserttags) | **PUT** /tags |
*TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket |
*TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets |
*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty |
@@ -247,6 +244,8 @@ Class | Method | HTTP request | Description
*UsersAdminApi* | [**searchUsersAdmin**](doc//UsersAdminApi.md#searchusersadmin) | **GET** /admin/users |
*UsersAdminApi* | [**updateUserAdmin**](doc//UsersAdminApi.md#updateuseradmin) | **PUT** /admin/users/{id} |
*UsersAdminApi* | [**updateUserPreferencesAdmin**](doc//UsersAdminApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences |
+*ViewApi* | [**getAssetsByOriginalPath**](doc//ViewApi.md#getassetsbyoriginalpath) | **GET** /view/folder |
+*ViewApi* | [**getUniqueOriginalPaths**](doc//ViewApi.md#getuniqueoriginalpaths) | **GET** /view/folder/unique-paths |
## Documentation For Models
@@ -260,8 +259,8 @@ Class | Method | HTTP request | Description
- [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md)
- [AddUsersDto](doc//AddUsersDto.md)
- [AdminOnboardingUpdateDto](doc//AdminOnboardingUpdateDto.md)
- - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
- [AlbumResponseDto](doc//AlbumResponseDto.md)
+ - [AlbumStatisticsResponseDto](doc//AlbumStatisticsResponseDto.md)
- [AlbumUserAddDto](doc//AlbumUserAddDto.md)
- [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
- [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)
@@ -289,6 +288,7 @@ Class | Method | HTTP request | Description
- [AssetMediaStatus](doc//AssetMediaStatus.md)
- [AssetOrder](doc//AssetOrder.md)
- [AssetResponseDto](doc//AssetResponseDto.md)
+ - [AssetStackResponseDto](doc//AssetStackResponseDto.md)
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
- [AssetTypeEnum](doc//AssetTypeEnum.md)
- [AudioCodec](doc//AudioCodec.md)
@@ -306,7 +306,6 @@ Class | Method | HTTP request | Description
- [CreateAlbumDto](doc//CreateAlbumDto.md)
- [CreateLibraryDto](doc//CreateLibraryDto.md)
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
- - [CreateTagDto](doc//CreateTagDto.md)
- [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
- [DownloadInfoDto](doc//DownloadInfoDto.md)
- [DownloadResponse](doc//DownloadResponse.md)
@@ -325,6 +324,8 @@ Class | Method | HTTP request | Description
- [FileReportDto](doc//FileReportDto.md)
- [FileReportFixDto](doc//FileReportFixDto.md)
- [FileReportItemDto](doc//FileReportItemDto.md)
+ - [FoldersResponse](doc//FoldersResponse.md)
+ - [FoldersUpdate](doc//FoldersUpdate.md)
- [ImageFormat](doc//ImageFormat.md)
- [JobCommand](doc//JobCommand.md)
- [JobCommandDto](doc//JobCommandDto.md)
@@ -343,12 +344,12 @@ Class | Method | HTTP request | Description
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
- [MapTheme](doc//MapTheme.md)
+ - [MemoriesResponse](doc//MemoriesResponse.md)
+ - [MemoriesUpdate](doc//MemoriesUpdate.md)
- [MemoryCreateDto](doc//MemoryCreateDto.md)
- [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md)
- - [MemoryResponse](doc//MemoryResponse.md)
- [MemoryResponseDto](doc//MemoryResponseDto.md)
- [MemoryType](doc//MemoryType.md)
- - [MemoryUpdate](doc//MemoryUpdate.md)
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
- [MergePersonDto](doc//MergePersonDto.md)
- [MetadataSearchDto](doc//MetadataSearchDto.md)
@@ -360,7 +361,9 @@ Class | Method | HTTP request | Description
- [PartnerResponseDto](doc//PartnerResponseDto.md)
- [PathEntityType](doc//PathEntityType.md)
- [PathType](doc//PathType.md)
+ - [PeopleResponse](doc//PeopleResponse.md)
- [PeopleResponseDto](doc//PeopleResponseDto.md)
+ - [PeopleUpdate](doc//PeopleUpdate.md)
- [PeopleUpdateDto](doc//PeopleUpdateDto.md)
- [PeopleUpdateItem](doc//PeopleUpdateItem.md)
- [Permission](doc//Permission.md)
@@ -373,8 +376,8 @@ Class | Method | HTTP request | Description
- [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
- - [RatingResponse](doc//RatingResponse.md)
- - [RatingUpdate](doc//RatingUpdate.md)
+ - [RatingsResponse](doc//RatingsResponse.md)
+ - [RatingsUpdate](doc//RatingsUpdate.md)
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
- [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md)
@@ -404,6 +407,9 @@ Class | Method | HTTP request | Description
- [SignUpDto](doc//SignUpDto.md)
- [SmartInfoResponseDto](doc//SmartInfoResponseDto.md)
- [SmartSearchDto](doc//SmartSearchDto.md)
+ - [StackCreateDto](doc//StackCreateDto.md)
+ - [StackResponseDto](doc//StackResponseDto.md)
+ - [StackUpdateDto](doc//StackUpdateDto.md)
- [SystemConfigDto](doc//SystemConfigDto.md)
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
- [SystemConfigImageDto](doc//SystemConfigImageDto.md)
@@ -427,8 +433,14 @@ Class | Method | HTTP request | Description
- [SystemConfigThemeDto](doc//SystemConfigThemeDto.md)
- [SystemConfigTrashDto](doc//SystemConfigTrashDto.md)
- [SystemConfigUserDto](doc//SystemConfigUserDto.md)
+ - [TagBulkAssetsDto](doc//TagBulkAssetsDto.md)
+ - [TagBulkAssetsResponseDto](doc//TagBulkAssetsResponseDto.md)
+ - [TagCreateDto](doc//TagCreateDto.md)
- [TagResponseDto](doc//TagResponseDto.md)
- - [TagTypeEnum](doc//TagTypeEnum.md)
+ - [TagUpdateDto](doc//TagUpdateDto.md)
+ - [TagUpsertDto](doc//TagUpsertDto.md)
+ - [TagsResponse](doc//TagsResponse.md)
+ - [TagsUpdate](doc//TagsUpdate.md)
- [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
- [TimeBucketSize](doc//TimeBucketSize.md)
- [ToneMapping](doc//ToneMapping.md)
@@ -439,8 +451,6 @@ Class | Method | HTTP request | Description
- [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateLibraryDto](doc//UpdateLibraryDto.md)
- [UpdatePartnerDto](doc//UpdatePartnerDto.md)
- - [UpdateStackParentDto](doc//UpdateStackParentDto.md)
- - [UpdateTagDto](doc//UpdateTagDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)
- [UserAdminCreateDto](doc//UserAdminCreateDto.md)
- [UserAdminDeleteDto](doc//UserAdminDeleteDto.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 4d33f1018cb52..d6ce89624cee6 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -51,9 +51,9 @@ part 'api/partners_api.dart';
part 'api/people_api.dart';
part 'api/search_api.dart';
part 'api/server_api.dart';
-part 'api/server_info_api.dart';
part 'api/sessions_api.dart';
part 'api/shared_links_api.dart';
+part 'api/stacks_api.dart';
part 'api/sync_api.dart';
part 'api/system_config_api.dart';
part 'api/system_metadata_api.dart';
@@ -62,6 +62,7 @@ part 'api/timeline_api.dart';
part 'api/trash_api.dart';
part 'api/users_api.dart';
part 'api/users_admin_api.dart';
+part 'api/view_api.dart';
part 'model/api_key_create_dto.dart';
part 'model/api_key_create_response_dto.dart';
@@ -72,8 +73,8 @@ part 'model/activity_response_dto.dart';
part 'model/activity_statistics_response_dto.dart';
part 'model/add_users_dto.dart';
part 'model/admin_onboarding_update_dto.dart';
-part 'model/album_count_response_dto.dart';
part 'model/album_response_dto.dart';
+part 'model/album_statistics_response_dto.dart';
part 'model/album_user_add_dto.dart';
part 'model/album_user_create_dto.dart';
part 'model/album_user_response_dto.dart';
@@ -101,6 +102,7 @@ part 'model/asset_media_size.dart';
part 'model/asset_media_status.dart';
part 'model/asset_order.dart';
part 'model/asset_response_dto.dart';
+part 'model/asset_stack_response_dto.dart';
part 'model/asset_stats_response_dto.dart';
part 'model/asset_type_enum.dart';
part 'model/audio_codec.dart';
@@ -118,7 +120,6 @@ part 'model/colorspace.dart';
part 'model/create_album_dto.dart';
part 'model/create_library_dto.dart';
part 'model/create_profile_image_response_dto.dart';
-part 'model/create_tag_dto.dart';
part 'model/download_archive_info.dart';
part 'model/download_info_dto.dart';
part 'model/download_response.dart';
@@ -137,6 +138,8 @@ part 'model/file_checksum_response_dto.dart';
part 'model/file_report_dto.dart';
part 'model/file_report_fix_dto.dart';
part 'model/file_report_item_dto.dart';
+part 'model/folders_response.dart';
+part 'model/folders_update.dart';
part 'model/image_format.dart';
part 'model/job_command.dart';
part 'model/job_command_dto.dart';
@@ -155,12 +158,12 @@ part 'model/logout_response_dto.dart';
part 'model/map_marker_response_dto.dart';
part 'model/map_reverse_geocode_response_dto.dart';
part 'model/map_theme.dart';
+part 'model/memories_response.dart';
+part 'model/memories_update.dart';
part 'model/memory_create_dto.dart';
part 'model/memory_lane_response_dto.dart';
-part 'model/memory_response.dart';
part 'model/memory_response_dto.dart';
part 'model/memory_type.dart';
-part 'model/memory_update.dart';
part 'model/memory_update_dto.dart';
part 'model/merge_person_dto.dart';
part 'model/metadata_search_dto.dart';
@@ -172,7 +175,9 @@ part 'model/partner_direction.dart';
part 'model/partner_response_dto.dart';
part 'model/path_entity_type.dart';
part 'model/path_type.dart';
+part 'model/people_response.dart';
part 'model/people_response_dto.dart';
+part 'model/people_update.dart';
part 'model/people_update_dto.dart';
part 'model/people_update_item.dart';
part 'model/permission.dart';
@@ -185,8 +190,8 @@ part 'model/places_response_dto.dart';
part 'model/purchase_response.dart';
part 'model/purchase_update.dart';
part 'model/queue_status_dto.dart';
-part 'model/rating_response.dart';
-part 'model/rating_update.dart';
+part 'model/ratings_response.dart';
+part 'model/ratings_update.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';
part 'model/reverse_geocoding_state_response_dto.dart';
@@ -216,6 +221,9 @@ part 'model/shared_link_type.dart';
part 'model/sign_up_dto.dart';
part 'model/smart_info_response_dto.dart';
part 'model/smart_search_dto.dart';
+part 'model/stack_create_dto.dart';
+part 'model/stack_response_dto.dart';
+part 'model/stack_update_dto.dart';
part 'model/system_config_dto.dart';
part 'model/system_config_f_fmpeg_dto.dart';
part 'model/system_config_image_dto.dart';
@@ -239,8 +247,14 @@ part 'model/system_config_template_storage_option_dto.dart';
part 'model/system_config_theme_dto.dart';
part 'model/system_config_trash_dto.dart';
part 'model/system_config_user_dto.dart';
+part 'model/tag_bulk_assets_dto.dart';
+part 'model/tag_bulk_assets_response_dto.dart';
+part 'model/tag_create_dto.dart';
part 'model/tag_response_dto.dart';
-part 'model/tag_type_enum.dart';
+part 'model/tag_update_dto.dart';
+part 'model/tag_upsert_dto.dart';
+part 'model/tags_response.dart';
+part 'model/tags_update.dart';
part 'model/time_bucket_response_dto.dart';
part 'model/time_bucket_size.dart';
part 'model/tone_mapping.dart';
@@ -251,8 +265,6 @@ part 'model/update_album_user_dto.dart';
part 'model/update_asset_dto.dart';
part 'model/update_library_dto.dart';
part 'model/update_partner_dto.dart';
-part 'model/update_stack_parent_dto.dart';
-part 'model/update_tag_dto.dart';
part 'model/usage_by_user_dto.dart';
part 'model/user_admin_create_dto.dart';
part 'model/user_admin_delete_dto.dart';
diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart
index fb81c04616742..eb2bb7c0bd9cb 100644
--- a/mobile/openapi/lib/api/albums_api.dart
+++ b/mobile/openapi/lib/api/albums_api.dart
@@ -218,47 +218,6 @@ class AlbumsApi {
}
}
- /// Performs an HTTP 'GET /albums/count' operation and returns the [Response].
- Future getAlbumCountWithHttpInfo() async {
- // ignore: prefer_const_declarations
- final path = r'/albums/count';
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- path,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- Future getAlbumCount() async {
- final response = await getAlbumCountWithHttpInfo();
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumCountResponseDto',) as AlbumCountResponseDto;
-
- }
- return null;
- }
-
/// Performs an HTTP 'GET /albums/{id}' operation and returns the [Response].
/// Parameters:
///
@@ -322,6 +281,47 @@ class AlbumsApi {
return null;
}
+ /// Performs an HTTP 'GET /albums/statistics' operation and returns the [Response].
+ Future getAlbumStatisticsWithHttpInfo() async {
+ // ignore: prefer_const_declarations
+ final path = r'/albums/statistics';
+
+ // ignore: prefer_final_locals
+ Object? postBody;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ const contentTypes = [];
+
+
+ return apiClient.invokeAPI(
+ path,
+ 'GET',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ Future getAlbumStatistics() async {
+ final response = await getAlbumStatisticsWithHttpInfo();
+ if (response.statusCode >= HttpStatus.badRequest) {
+ throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+ }
+ // When a remote server returns no body with a status of 204, we shall not decode it.
+ // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+ // FormatException when trying to decode an empty string.
+ if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+ return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumStatisticsResponseDto',) as AlbumStatisticsResponseDto;
+
+ }
+ return null;
+ }
+
/// Performs an HTTP 'GET /albums' operation and returns the [Response].
/// Parameters:
///
diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart
index d7d386130bc02..ceba3574cd17a 100644
--- a/mobile/openapi/lib/api/assets_api.dart
+++ b/mobile/openapi/lib/api/assets_api.dart
@@ -804,45 +804,6 @@ class AssetsApi {
}
}
- /// Performs an HTTP 'PUT /assets/stack/parent' operation and returns the [Response].
- /// Parameters:
- ///
- /// * [UpdateStackParentDto] updateStackParentDto (required):
- Future updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async {
- // ignore: prefer_const_declarations
- final path = r'/assets/stack/parent';
-
- // ignore: prefer_final_locals
- Object? postBody = updateStackParentDto;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = ['application/json'];
-
-
- return apiClient.invokeAPI(
- path,
- 'PUT',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// Parameters:
- ///
- /// * [UpdateStackParentDto] updateStackParentDto (required):
- Future updateStackParent(UpdateStackParentDto updateStackParentDto,) async {
- final response = await updateStackParentWithHttpInfo(updateStackParentDto,);
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- }
-
/// Performs an HTTP 'POST /assets' operation and returns the [Response].
/// Parameters:
///
diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart
index e1e09ae4b2dda..96cb3c2ef0ad2 100644
--- a/mobile/openapi/lib/api/deprecated_api.dart
+++ b/mobile/openapi/lib/api/deprecated_api.dart
@@ -16,50 +16,6 @@ class DeprecatedApi {
final ApiClient apiClient;
- /// This property was deprecated in v1.107.0
- ///
- /// Note: This method returns the HTTP [Response].
- Future getAboutInfoWithHttpInfo() async {
- // ignore: prefer_const_declarations
- final path = r'/server-info/about';
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- path,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// This property was deprecated in v1.107.0
- Future getAboutInfo() async {
- final response = await getAboutInfoWithHttpInfo();
- if (response.statusCode >= HttpStatus.badRequest) {
- throw ApiException(response.statusCode, await _decodeBodyBytes(response));
- }
- // When a remote server returns no body with a status of 204, we shall not decode it.
- // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
- // FormatException when trying to decode an empty string.
- if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
- return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerAboutResponseDto',) as ServerAboutResponseDto;
-
- }
- return null;
- }
-
/// This property was deprecated in v1.113.0
///
/// Note: This method returns the HTTP [Response].
@@ -115,356 +71,4 @@ class DeprecatedApi {
}
return null;
}
-
- /// This property was deprecated in v1.107.0
- ///
- /// Note: This method returns the HTTP [Response].
- Future getServerConfigWithHttpInfo() async {
- // ignore: prefer_const_declarations
- final path = r'/server-info/config';
-
- // ignore: prefer_final_locals
- Object? postBody;
-
- final queryParams = [];
- final headerParams = {};
- final formParams = {};
-
- const contentTypes = [];
-
-
- return apiClient.invokeAPI(
- path,
- 'GET',
- queryParams,
- postBody,
- headerParams,
- formParams,
- contentTypes.isEmpty ? null : contentTypes.first,
- );
- }
-
- /// This property was deprecated in v1.107.0
- Future