Merge branch 'main' into edit-date-time-action

This commit is contained in:
Daimolean
2025-07-24 02:12:56 +08:00
committed by GitHub
73 changed files with 2252 additions and 1341 deletions

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,6 @@ class RemoteAsset extends BaseAsset {
final DateTime? localDateTime;
final String ownerId;
final String? stackId;
final int stackCount;
const RemoteAsset({
required this.id,
@@ -36,7 +35,6 @@ class RemoteAsset extends BaseAsset {
this.localDateTime,
super.livePhotoVideoId,
this.stackId,
this.stackCount = 0,
});
@override
@@ -64,7 +62,6 @@ class RemoteAsset extends BaseAsset {
visibility: $visibility,
localDateTime: ${localDateTime ?? "<NA>"},
stackId: ${stackId ?? "<NA>"},
stackCount: $stackCount,
checksum: $checksum,
livePhotoVideoId: ${livePhotoVideoId ?? "<NA>"},
}''';
@@ -81,8 +78,7 @@ class RemoteAsset extends BaseAsset {
thumbHash == other.thumbHash &&
visibility == other.visibility &&
localDateTime == other.localDateTime &&
stackId == other.stackId &&
stackCount == other.stackCount;
stackId == other.stackId;
}
@override
@@ -94,8 +90,7 @@ class RemoteAsset extends BaseAsset {
thumbHash.hashCode ^
visibility.hashCode ^
localDateTime.hashCode ^
stackId.hashCode ^
stackCount.hashCode;
stackId.hashCode;
RemoteAsset copyWith({
String? id,
@@ -115,7 +110,6 @@ class RemoteAsset extends BaseAsset {
DateTime? localDateTime,
String? livePhotoVideoId,
String? stackId,
int? stackCount,
}) {
return RemoteAsset(
id: id ?? this.id,
@@ -135,7 +129,6 @@ class RemoteAsset extends BaseAsset {
localDateTime: localDateTime ?? this.localDateTime,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
stackCount: stackCount ?? this.stackCount,
);
}
}

View File

@@ -1,76 +1,68 @@
import 'remote_asset.entity.dart';
import 'local_asset.entity.dart';
import 'stack.entity.dart';
import 'local_asset.entity.dart';
import 'local_album.entity.dart';
import 'local_album_asset.entity.dart';
mergedAsset: SELECT * FROM
(
SELECT
rae.id as remote_id,
lae.id as local_id,
rae.name,
rae."type",
rae.created_at,
rae.updated_at,
rae.width,
rae.height,
rae.duration_in_seconds,
rae.is_favorite,
rae.thumb_hash,
rae.checksum,
rae.owner_id,
rae.live_photo_video_id,
0 as orientation,
rae.stack_id,
COALESCE(stack_count.total_count, 0) AS stack_count
FROM
remote_asset_entity rae
LEFT JOIN
local_asset_entity lae ON rae.checksum = lae.checksum
LEFT JOIN
stack_entity se ON rae.stack_id = se.id
LEFT JOIN
(SELECT
stack_id,
COUNT(*) AS total_count
FROM remote_asset_entity
WHERE deleted_at IS NULL
AND visibility = 0
AND stack_id IS NOT NULL
GROUP BY stack_id
) AS stack_count ON rae.stack_id = stack_count.stack_id
WHERE
rae.deleted_at IS NULL
AND rae.visibility = 0
AND rae.owner_id in ?
AND (
rae.stack_id IS NULL
OR rae.id = se.primary_asset_id
)
UNION ALL
SELECT
NULL as remote_id,
lae.id as local_id,
lae.name,
lae."type",
lae.created_at,
lae.updated_at,
lae.width,
lae.height,
lae.duration_in_seconds,
lae.is_favorite,
NULL as thumb_hash,
lae.checksum,
NULL as owner_id,
NULL as live_photo_video_id,
lae.orientation,
NULL as stack_id,
0 AS stack_count
FROM
local_asset_entity lae
LEFT JOIN
remote_asset_entity rae ON rae.checksum = lae.checksum
WHERE
rae.id IS NULL
mergedAsset:
SELECT
rae.id as remote_id,
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id,
rae.name,
rae."type",
rae.created_at as created_at,
rae.updated_at,
rae.width,
rae.height,
rae.duration_in_seconds,
rae.is_favorite,
rae.thumb_hash,
rae.checksum,
rae.owner_id,
rae.live_photo_video_id,
0 as orientation,
rae.stack_id
FROM
remote_asset_entity rae
LEFT JOIN
stack_entity se ON rae.stack_id = se.id
WHERE
rae.deleted_at IS NULL
AND rae.visibility = 0 -- timeline visibility
AND rae.owner_id in ?
AND (
rae.stack_id IS NULL
OR rae.id = se.primary_asset_id
)
UNION ALL
SELECT
NULL as remote_id,
lae.id as local_id,
lae.name,
lae."type",
lae.created_at as created_at,
lae.updated_at,
lae.width,
lae.height,
lae.duration_in_seconds,
lae.is_favorite,
NULL as thumb_hash,
lae.checksum,
NULL as owner_id,
NULL as live_photo_video_id,
lae.orientation,
NULL as stack_id
FROM
local_asset_entity lae
WHERE NOT EXISTS (
SELECT 1 FROM remote_asset_entity rae WHERE rae.checksum = lae.checksum
)
AND EXISTS (
SELECT 1 FROM local_album_asset_entity laa
INNER JOIN local_album_entity la on laa.album_id = la.id
WHERE laa.asset_id = lae.id AND la.backup_selection = 0 -- selected
)
ORDER BY created_at DESC
LIMIT $limit;
@@ -85,17 +77,14 @@ SELECT
FROM
(
SELECT
rae.name,
rae.created_at
FROM
remote_asset_entity rae
LEFT JOIN
local_asset_entity lae ON rae.checksum = lae.checksum
LEFT JOIN
stack_entity se ON rae.stack_id = se.id
WHERE
rae.deleted_at IS NULL
AND rae.visibility = 0
AND rae.visibility = 0 -- timeline visibility
AND rae.owner_id in ?
AND (
rae.stack_id IS NULL
@@ -103,14 +92,18 @@ FROM
)
UNION ALL
SELECT
lae.name,
lae.created_at
FROM
local_asset_entity lae
LEFT JOIN
remote_asset_entity rae ON rae.checksum = lae.checksum
LEFT JOIN
local_album_asset_entity laa ON laa.asset_id = lae.id
LEFT JOIN
local_album_entity la ON la.id = laa.album_id
WHERE
rae.id IS NULL
AND la.backup_selection = 0 -- selected
)
GROUP BY bucket_date
ORDER BY bucket_date DESC;

View File

@@ -3,24 +3,29 @@
import 'package:drift/drift.dart' as i0;
import 'package:drift/internal/modular.dart' as i1;
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i7;
class MergedAssetDrift extends i1.ModularAccessor {
MergedAssetDrift(i0.GeneratedDatabase db) : super(db);
i0.Selectable<MergedAssetResult> mergedAsset(List<String> var1,
{required i0.Limit limit}) {
{required MergedAsset$limit limit}) {
var $arrayStartIndex = 1;
final expandedvar1 = $expandVar($arrayStartIndex, var1.length);
$arrayStartIndex += var1.length;
final generatedlimit = $write(limit, startIndex: $arrayStartIndex);
final generatedlimit = $write(limit(alias(this.localAssetEntity, 'lae')),
startIndex: $arrayStartIndex);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, COALESCE(stack_count.total_count, 0) AS stack_count FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum LEFT JOIN stack_entity AS se ON rae.stack_id = se.id LEFT JOIN (SELECT stack_id, COUNT(*) AS total_count FROM remote_asset_entity WHERE deleted_at IS NULL AND visibility = 0 AND stack_id IS NOT NULL GROUP BY stack_id) AS stack_count ON rae.stack_id = stack_count.stack_id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, 0 AS stack_count FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in var1) i0.Variable<String>($),
...generatedlimit.introducedVariables
@@ -29,12 +34,14 @@ class MergedAssetDrift extends i1.ModularAccessor {
remoteAssetEntity,
localAssetEntity,
stackEntity,
localAlbumAssetEntity,
localAlbumEntity,
...generatedlimit.watchedTables,
}).map((i0.QueryRow row) => MergedAssetResult(
remoteId: row.readNullable<String>('remote_id'),
localId: row.readNullable<String>('local_id'),
name: row.read<String>('name'),
type: i3.$RemoteAssetEntityTable.$convertertype
type: i4.$RemoteAssetEntityTable.$convertertype
.fromSql(row.read<int>('type')),
createdAt: row.read<DateTime>('created_at'),
updatedAt: row.read<DateTime>('updated_at'),
@@ -48,7 +55,6 @@ class MergedAssetDrift extends i1.ModularAccessor {
livePhotoVideoId: row.readNullable<String>('live_photo_video_id'),
orientation: row.read<int>('orientation'),
stackId: row.readNullable<String>('stack_id'),
stackCount: row.read<int>('stack_count'),
));
}
@@ -58,30 +64,39 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandedvar2 = $expandVar($arrayStartIndex, var2.length);
$arrayStartIndex += var2.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC',
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.created_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum LEFT JOIN local_album_asset_entity AS laa ON laa.asset_id = lae.id LEFT JOIN local_album_entity AS la ON la.id = laa.album_id WHERE rae.id IS NULL AND la.backup_selection = 0) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in var2) i0.Variable<String>($)
],
readsFrom: {
remoteAssetEntity,
localAssetEntity,
stackEntity,
localAssetEntity,
localAlbumAssetEntity,
localAlbumEntity,
}).map((i0.QueryRow row) => MergedBucketResult(
assetCount: row.read<int>('asset_count'),
bucketDate: row.read<String>('bucket_date'),
));
}
i3.$RemoteAssetEntityTable get remoteAssetEntity =>
i4.$RemoteAssetEntityTable get remoteAssetEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity');
i4.$LocalAssetEntityTable get localAssetEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i4.$LocalAssetEntityTable>('local_asset_entity');
.resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity');
i5.$StackEntityTable get stackEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i5.$StackEntityTable>('stack_entity');
i3.$LocalAssetEntityTable get localAssetEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i3.$LocalAssetEntityTable>('local_asset_entity');
i6.$LocalAlbumAssetEntityTable get localAlbumAssetEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i6.$LocalAlbumAssetEntityTable>(
'local_album_asset_entity');
i7.$LocalAlbumEntityTable get localAlbumEntity =>
i1.ReadDatabaseContainer(attachedDatabase)
.resultSet<i7.$LocalAlbumEntityTable>('local_album_entity');
}
class MergedAssetResult {
@@ -101,7 +116,6 @@ class MergedAssetResult {
final String? livePhotoVideoId;
final int orientation;
final String? stackId;
final int stackCount;
MergedAssetResult({
this.remoteId,
this.localId,
@@ -119,10 +133,11 @@ class MergedAssetResult {
this.livePhotoVideoId,
required this.orientation,
this.stackId,
required this.stackCount,
});
}
typedef MergedAsset$limit = i0.Limit Function(i3.$LocalAssetEntityTable lae);
class MergedBucketResult {
final int assetCount;
final String bucketDate;

View File

@@ -50,7 +50,7 @@ class DriftBackupRepository extends DriftDatabaseRepository {
return query.get().then((rows) => rows.length);
}
Future<int> getRemainderCount() async {
Future<int> getRemainderCount(String userId) async {
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
@@ -74,7 +74,8 @@ class DriftBackupRepository extends DriftDatabaseRepository {
..where(
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isNull() &
(_db.remoteAssetEntity.id.isNull() |
_db.remoteAssetEntity.ownerId.equals(userId).not()) &
_db.localAlbumAssetEntity.assetId
.isNotInQuery(_getExcludedSubquery()),
);
@@ -82,7 +83,7 @@ class DriftBackupRepository extends DriftDatabaseRepository {
return query.get().then((rows) => rows.length);
}
Future<int> getBackupCount() async {
Future<int> getBackupCount(String userId) async {
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns(
[_db.localAlbumAssetEntity.assetId],
@@ -109,6 +110,7 @@ class DriftBackupRepository extends DriftDatabaseRepository {
_db.localAlbumEntity.backupSelection
.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isNotNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.localAlbumAssetEntity.assetId
.isNotInQuery(_getExcludedSubquery()),
);
@@ -116,7 +118,7 @@ class DriftBackupRepository extends DriftDatabaseRepository {
return query.get().then((rows) => rows.length);
}
Future<List<LocalAsset>> getCandidates() async {
Future<List<LocalAsset>> getCandidates(String userId) async {
final selectedAlbumIds = _db.localAlbumEntity.selectOnly(distinct: true)
..addColumns([_db.localAlbumEntity.id])
..where(
@@ -141,6 +143,7 @@ class DriftBackupRepository extends DriftDatabaseRepository {
..addColumns([_db.remoteAssetEntity.checksum])
..where(
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) &
_db.remoteAssetEntity.ownerId.equals(userId) &
lae.checksum.isNotNull(),
),
) &

View File

@@ -4,8 +4,8 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
@@ -97,7 +97,9 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.alterTable(TableMigration(v3.stackEntity));
},
from3To4: (m, v4) async {
// Thumbnail path column got removed from person_entity
await m.alterTable(TableMigration(v4.personEntity));
// asset_face_entity is added
await m.create(v4.assetFaceEntity);
},
),

View File

@@ -5,17 +5,17 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i2;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i7;
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i9;
@@ -43,17 +43,17 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i1.$UserEntityTable userEntity = i1.$UserEntityTable(this);
late final i2.$RemoteAssetEntityTable remoteAssetEntity =
i2.$RemoteAssetEntityTable(this);
late final i3.$LocalAssetEntityTable localAssetEntity =
i3.$LocalAssetEntityTable(this);
late final i4.$StackEntityTable stackEntity = i4.$StackEntityTable(this);
late final i5.$UserMetadataEntityTable userMetadataEntity =
i5.$UserMetadataEntityTable(this);
late final i6.$PartnerEntityTable partnerEntity =
i6.$PartnerEntityTable(this);
late final i7.$LocalAlbumEntityTable localAlbumEntity =
i7.$LocalAlbumEntityTable(this);
late final i8.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
i8.$LocalAlbumAssetEntityTable(this);
late final i3.$StackEntityTable stackEntity = i3.$StackEntityTable(this);
late final i4.$LocalAssetEntityTable localAssetEntity =
i4.$LocalAssetEntityTable(this);
late final i5.$LocalAlbumEntityTable localAlbumEntity =
i5.$LocalAlbumEntityTable(this);
late final i6.$LocalAlbumAssetEntityTable localAlbumAssetEntity =
i6.$LocalAlbumAssetEntityTable(this);
late final i7.$UserMetadataEntityTable userMetadataEntity =
i7.$UserMetadataEntityTable(this);
late final i8.$PartnerEntityTable partnerEntity =
i8.$PartnerEntityTable(this);
late final i9.$RemoteExifEntityTable remoteExifEntity =
i9.$RemoteExifEntityTable(this);
late final i10.$RemoteAlbumEntityTable remoteAlbumEntity =
@@ -77,15 +77,15 @@ abstract class $Drift extends i0.GeneratedDatabase {
List<i0.DatabaseSchemaEntity> get allSchemaEntities => [
userEntity,
remoteAssetEntity,
localAssetEntity,
stackEntity,
i3.idxLocalAssetChecksum,
localAssetEntity,
localAlbumEntity,
localAlbumAssetEntity,
i4.idxLocalAssetChecksum,
i2.uQRemoteAssetOwnerChecksum,
i2.idxRemoteAssetChecksum,
userMetadataEntity,
partnerEntity,
localAlbumEntity,
localAlbumAssetEntity,
remoteExifEntity,
remoteAlbumEntity,
remoteAlbumAssetEntity,
@@ -113,6 +113,22 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('local_asset_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('local_album_asset_entity',
kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('local_album_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('local_album_asset_entity',
kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('user_entity',
limitUpdateKind: i0.UpdateKind.delete),
@@ -135,22 +151,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
i0.TableUpdate('partner_entity', kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('local_asset_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('local_album_asset_entity',
kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('local_album_entity',
limitUpdateKind: i0.UpdateKind.delete),
result: [
i0.TableUpdate('local_album_asset_entity',
kind: i0.UpdateKind.delete),
],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName('remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete),
@@ -260,18 +260,18 @@ class $DriftManager {
i1.$$UserEntityTableTableManager(_db, _db.userEntity);
i2.$$RemoteAssetEntityTableTableManager get remoteAssetEntity =>
i2.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity);
i3.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i3.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i4.$$StackEntityTableTableManager get stackEntity =>
i4.$$StackEntityTableTableManager(_db, _db.stackEntity);
i5.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i5.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i6.$$PartnerEntityTableTableManager get partnerEntity =>
i6.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i7.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i7.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i8.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i8
i3.$$StackEntityTableTableManager get stackEntity =>
i3.$$StackEntityTableTableManager(_db, _db.stackEntity);
i4.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i4.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i5.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i5.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i6.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i6
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
i7.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i7.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i8.$$PartnerEntityTableTableManager get partnerEntity =>
i8.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i9.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i9.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i10.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>

View File

@@ -1273,15 +1273,15 @@ final class Schema4 extends i0.VersionedSchema {
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
localAssetEntity,
stackEntity,
localAssetEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
uQRemoteAssetOwnerChecksum,
idxRemoteAssetChecksum,
userMetadataEntity,
partnerEntity,
localAlbumEntity,
localAlbumAssetEntity,
remoteExifEntity,
remoteAlbumEntity,
remoteAlbumAssetEntity,
@@ -1342,6 +1342,24 @@ final class Schema4 extends i0.VersionedSchema {
attachedDatabase: database,
),
alias: null);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_75,
],
attachedDatabase: database,
),
alias: null);
late final Shape2 localAssetEntity = Shape2(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
@@ -1366,9 +1384,9 @@ final class Schema4 extends i0.VersionedSchema {
attachedDatabase: database,
),
alias: null);
late final Shape3 stackEntity = Shape3(
late final Shape6 localAlbumEntity = Shape6(
source: i0.VersionedTable(
entityName: 'stack_entity',
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
@@ -1376,10 +1394,26 @@ final class Schema4 extends i0.VersionedSchema {
],
columns: [
_column_0,
_column_9,
_column_1,
_column_5,
_column_15,
_column_75,
_column_31,
_column_32,
_column_33,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 localAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(asset_id, album_id)',
],
columns: [
_column_34,
_column_35,
],
attachedDatabase: database,
),
@@ -1423,40 +1457,6 @@ final class Schema4 extends i0.VersionedSchema {
attachedDatabase: database,
),
alias: null);
late final Shape6 localAlbumEntity = Shape6(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_33,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 localAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: [
'PRIMARY KEY(asset_id, album_id)',
],
columns: [
_column_34,
_column_35,
],
attachedDatabase: database,
),
alias: null);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',

View File

@@ -32,49 +32,21 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
Stream<RemoteAsset?> watchAsset(String id) {
final stackCountRef = _db.stackEntity.id.count();
final query = _db.remoteAssetEntity.select().addColumns([
_db.localAssetEntity.id,
_db.stackEntity.primaryAssetId,
stackCountRef,
]).join([
leftOuterJoin(
_db.localAssetEntity,
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
useColumns: false,
),
leftOuterJoin(
_db.stackEntity,
_db.stackEntity.primaryAssetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity.createAlias('stacked_assets'),
_db.stackEntity.id.equalsExp(
_db.remoteAssetEntity.createAlias('stacked_assets').stackId,
),
useColumns: false,
),
])
..where(_db.remoteAssetEntity.id.equals(id))
..groupBy([
_db.remoteAssetEntity.id,
_db.localAssetEntity.id,
_db.stackEntity.primaryAssetId,
])
..limit(1);
return query.map((row) {
final asset = row.readTable(_db.remoteAssetEntity).toDto();
final primaryAssetId = row.read(_db.stackEntity.primaryAssetId);
final stackCount =
primaryAssetId == id ? (row.read(stackCountRef) ?? 0) : 0;
return asset.copyWith(
localId: row.read(_db.localAssetEntity.id),
stackCount: stackCount,
);
return asset.copyWith(localId: row.read(_db.localAssetEntity.id));
}).watchSingleOrNull();
}

View File

@@ -71,7 +71,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
required int count,
}) {
return _db.mergedAssetDrift
.mergedAsset(userIds, limit: Limit(count, offset))
.mergedAsset(userIds, limit: (_) => Limit(count, offset))
.map(
(row) => row.remoteId != null && row.ownerId != null
? RemoteAsset(
@@ -90,7 +90,6 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
durationInSeconds: row.durationInSeconds,
livePhotoVideoId: row.livePhotoVideoId,
stackId: row.stackId,
stackCount: row.stackCount,
)
: LocalAsset(
id: row.localId!,
@@ -142,6 +141,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false,
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])
..addColumns([assetCountExp, dateExp])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))

View File

@@ -203,6 +203,19 @@ class ImmichAppState extends ConsumerState<ImmichApp>
),
progressBar: true,
);
FileDownloader().configureNotificationForGroup(
kManualUploadGroup,
running: TaskNotification(
'uploading_media'.tr(),
'${'file_name'.tr()}: {displayName}',
),
complete: TaskNotification(
'upload_finished'.tr(),
'${'file_name'.tr()}: {displayName}',
),
progressBar: true,
);
}
Future<DeepLink> _deepLinkBuilder(PlatformDeepLink deepLink) async {

View File

@@ -9,6 +9,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
@@ -24,12 +25,24 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
@override
void initState() {
super.initState();
ref.read(driftBackupProvider.notifier).getBackupStatus();
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
}
Future<void> startBackup() async {
await ref.read(driftBackupProvider.notifier).getBackupStatus();
await ref.read(driftBackupProvider.notifier).backup();
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await ref
.read(driftBackupProvider.notifier)
.getBackupStatus(currentUser.id);
await ref.read(driftBackupProvider.notifier).backup(currentUser.id);
}
Future<void> stopBackup() async {
@@ -207,7 +220,13 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
trailing: ElevatedButton(
onPressed: () async {
await context.pushRoute(const DriftBackupAlbumSelectionRoute());
ref.read(driftBackupProvider.notifier).getBackupStatus();
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
ref
.read(driftBackupProvider.notifier)
.getBackupStatus(currentUser.id);
},
child: const Text(
"select",

View File

@@ -4,17 +4,17 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.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_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@RoutePage()
class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget {
@@ -92,7 +92,15 @@ class _DriftBackupAlbumSelectionPageState
if (didPop && !_hasPopped) {
_hasPopped = true;
await ref.read(driftBackupProvider.notifier).getBackupStatus();
super.initState();
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await ref
.read(driftBackupProvider.notifier)
.getBackupStatus(currentUser.id);
final currentTotalAssetCount =
ref.read(driftBackupProvider.select((p) => p.totalCount));
@@ -107,7 +115,7 @@ class _DriftBackupAlbumSelectionPageState
final backupNotifier = ref.read(driftBackupProvider.notifier);
backupNotifier.cancel().then((_) {
backupNotifier.backup();
backupNotifier.backup(currentUser.id);
});
}
}

View File

@@ -2,10 +2,14 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
@@ -43,6 +47,17 @@ class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
albumNotifier.dispose();
}
// Cancel uploads
await Store.put(StoreKey.backgroundBackup, false);
ref.read(backupProvider.notifier).configureBackgroundBackup(
enabled: false,
onBatteryInfo: () {},
onError: (_) {},
);
ref.read(backupProvider.notifier).setAutoBackup(false);
ref.read(backupProvider.notifier).cancelBackup();
ref.read(manualUploadProvider.notifier).cancelBackup();
// Start listening to new websocket events
ref.read(websocketProvider.notifier).stopListenToOldEvents();
ref.read(websocketProvider.notifier).startListeningToBetaEvents();

View File

@@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
@@ -38,7 +39,14 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
await runNewSync(ref, full: true).then((_) async {
if (isEnableBackup) {
await ref.read(driftBackupProvider.notifier).handleBackupResume();
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await ref
.read(driftBackupProvider.notifier)
.handleBackupResume(currentUser.id);
}
});
});

View File

@@ -1,16 +1,50 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UploadActionButton extends ConsumerWidget {
const UploadActionButton({super.key});
final ActionSource source;
const UploadActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).upload(source);
final successMessage = 'upload_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success
? successMessage
: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
ref.read(multiSelectProvider.notifier).reset();
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.backup_outlined,
label: "upload".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@@ -6,11 +6,7 @@ class StackChildrenNotifier
extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset?> {
@override
Future<List<RemoteAsset>> build(BaseAsset? asset) async {
if (asset == null ||
asset is! RemoteAsset ||
asset.stackId == null ||
// The stackCount check is to ensure we only fetch stacks for timelines that have stacks
asset.stackCount == 0) {
if (asset == null || asset is! RemoteAsset || asset.stackId == null) {
return const [];
}

View File

@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -37,6 +38,8 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly)
const UploadActionButton(source: ActionSource.viewer),
if (asset.hasRemote && isOwner)
const ArchiveActionButton(source: ActionSource.viewer),
];
@@ -50,31 +53,30 @@ class ViewerBottomBar extends ConsumerWidget {
duration: Durations.short4,
child: isSheetOpen
? const SizedBox.shrink()
: SafeArea(
child: Theme(
data: context.themeData.copyWith(
iconTheme:
const IconThemeData(size: 22, color: Colors.white),
textTheme: context.themeData.textTheme.copyWith(
labelLarge:
context.themeData.textTheme.labelLarge?.copyWith(
color: Colors.white,
),
: Theme(
data: context.themeData.copyWith(
iconTheme:
const IconThemeData(size: 22, color: Colors.white),
textTheme: context.themeData.textTheme.copyWith(
labelLarge:
context.themeData.textTheme.labelLarge?.copyWith(
color: Colors.white,
),
),
child: Container(
height: asset.isVideo ? 160 : 80,
color: Colors.black.withAlpha(125),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (asset.isVideo) const VideoControls(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: actions,
),
],
),
),
child: Container(
height: context.padding.bottom + (asset.isVideo ? 160 : 80),
color: Colors.black.withAlpha(125),
padding: EdgeInsets.only(bottom: context.padding.bottom),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (asset.isVideo) const VideoControls(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: actions,
),
],
),
),
),

View File

@@ -63,7 +63,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
],
if (asset.storage == AssetState.local) ...[
const DeleteLocalActionButton(source: ActionSource.viewer),
const UploadActionButton(),
const UploadActionButton(source: ActionSource.timeline),
],
];

View File

@@ -27,6 +27,26 @@ import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
bool _isCurrentAsset(
BaseAsset asset,
BaseAsset? currentAsset,
) {
if (asset is RemoteAsset) {
return switch (currentAsset) {
RemoteAsset remoteAsset => remoteAsset.id == asset.id,
LocalAsset localAsset => localAsset.remoteId == asset.id,
_ => false,
};
} else if (asset is LocalAsset) {
return switch (currentAsset) {
RemoteAsset remoteAsset => remoteAsset.localId == asset.id,
LocalAsset localAsset => localAsset.id == asset.id,
_ => false,
};
}
return false;
}
class NativeVideoViewer extends HookConsumerWidget {
final BaseAsset asset;
final bool showControls;
@@ -56,7 +76,7 @@ class NativeVideoViewer extends HookConsumerWidget {
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
final currentAsset = useState(ref.read(currentAssetNotifier));
final isCurrent = currentAsset.value == asset;
final isCurrent = _isCurrentAsset(asset, currentAsset.value);
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible = useState(Platform.isIOS && asset.hasLocal);

View File

@@ -53,7 +53,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(),
const UploadActionButton(source: ActionSource.timeline),
],
],
);

View File

@@ -53,7 +53,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(),
const UploadActionButton(source: ActionSource.timeline),
],
],
);

View File

@@ -56,7 +56,7 @@ class GeneralBottomSheet extends ConsumerWidget {
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(),
const UploadActionButton(source: ActionSource.timeline),
],
],
);

View File

@@ -18,7 +18,7 @@ class LocalAlbumBottomSheet extends ConsumerWidget {
actions: [
ShareActionButton(source: ActionSource.timeline),
DeleteLocalActionButton(source: ActionSource.timeline),
UploadActionButton(),
UploadActionButton(source: ActionSource.timeline),
],
);
}

View File

@@ -56,7 +56,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
],
if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(),
const UploadActionButton(source: ActionSource.timeline),
],
RemoveFromAlbumActionButton(
source: ActionSource.timeline,

View File

@@ -54,7 +54,7 @@ class ThumbnailTile extends ConsumerWidget {
: const BoxDecoration();
final hasStack =
asset is RemoteAsset && (asset as RemoteAsset).stackCount > 0;
asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
return Stack(
children: [
@@ -86,9 +86,7 @@ class ThumbnailTile extends ConsumerWidget {
right: 10.0,
top: asset.isVideo ? 24.0 : 6.0,
),
child: _StackIndicator(
stackCount: (asset as RemoteAsset).stackCount,
),
child: const _TileOverlayIcon(Icons.burst_mode_rounded),
),
),
if (asset.isVideo)
@@ -198,40 +196,6 @@ class _SelectionIndicator extends StatelessWidget {
}
}
class _StackIndicator extends StatelessWidget {
final int stackCount;
const _StackIndicator({required this.stackCount});
@override
Widget build(BuildContext context) {
return Row(
spacing: 3,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
// CrossAxisAlignment.start looks more centered vertically than CrossAxisAlignment.center
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
stackCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
blurRadius: 5.0,
color: Color.fromRGBO(0, 0, 0, 0.6),
),
],
),
),
const _TileOverlayIcon(Icons.burst_mode_rounded),
],
);
}
}
class _VideoIndicator extends StatelessWidget {
final Duration duration;
const _VideoIndicator(this.duration);

View File

@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
@@ -110,7 +111,14 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
.getSetting(AppSettingsEnum.enableBackup);
if (isEnableBackup) {
await _ref.read(driftBackupProvider.notifier).handleBackupResume();
final currentUser = _ref.read(currentUserProvider);
if (currentUser == null) {
return;
}
await _ref
.read(driftBackupProvider.notifier)
.handleBackupResume(currentUser.id);
}
});
} catch (e, stackTrace) {

View File

@@ -329,11 +329,11 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
);
}
Future<void> getBackupStatus() async {
Future<void> getBackupStatus(String userId) async {
final [totalCount, backupCount, remainderCount] = await Future.wait([
_backupService.getTotalCount(),
_backupService.getBackupCount(),
_backupService.getRemainderCount(),
_backupService.getBackupCount(userId),
_backupService.getRemainderCount(userId),
]);
state = state.copyWith(
@@ -343,8 +343,8 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
);
}
Future<void> backup() {
return _backupService.backup(_updateEnqueueCount);
Future<void> backup(String userId) {
return _backupService.backup(userId, _updateEnqueueCount);
}
void _updateEnqueueCount(EnqueueStatus status) {
@@ -379,11 +379,11 @@ class ExpBackupNotifier extends StateNotifier<DriftBackupState> {
}
}
Future<void> handleBackupResume() async {
Future<void> handleBackupResume(String userId) async {
final tasks = await FileDownloader().allTasks(group: kBackupGroup);
if (tasks.isEmpty) {
// Start a new backup queue
await backup();
await backup(userId);
}
debugPrint("Tasks to resume: ${tasks.length}");

View File

@@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/drift_backup.service.dart';
import 'package:immich_mobile/services/timeline.service.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -32,12 +33,14 @@ class ActionResult {
class ActionNotifier extends Notifier<void> {
final Logger _logger = Logger('ActionNotifier');
late ActionService _service;
late DriftBackupService _backupService;
ActionNotifier() : super();
@override
void build() {
_service = ref.watch(actionServiceProvider);
_backupService = ref.watch(driftBackupServiceProvider);
}
List<String> _getRemoteIdsForSource(ActionSource source) {
@@ -390,6 +393,21 @@ class ActionNotifier extends Notifier<void> {
);
}
}
Future<ActionResult> upload(ActionSource source) async {
final assets = _getAssets(source).whereType<LocalAsset>().toList();
try {
await _backupService.manualBackup(assets);
return ActionResult(count: assets.length, success: true);
} catch (error, stack) {
_logger.severe('Failed manually upload assets', error, stack);
return ActionResult(
count: assets.length,
success: false,
error: error.toString(),
);
}
}
}
extension on Iterable<RemoteAsset> {

View File

@@ -20,6 +20,11 @@ class UploadRepository {
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (_) => {},
taskProgressCallback: (_) => {},
);
}
void enqueueAll(List<UploadTask> tasks) {

View File

@@ -26,6 +26,7 @@ final driftBackupServiceProvider = Provider<DriftBackupService>(
),
);
// TODO: Rename to UploadService after removing Isar
class DriftBackupService {
DriftBackupService(
this._backupRepository,
@@ -48,20 +49,39 @@ class DriftBackupService {
return _backupRepository.getTotalCount();
}
Future<int> getRemainderCount() {
return _backupRepository.getRemainderCount();
Future<int> getRemainderCount(String userId) {
return _backupRepository.getRemainderCount(userId);
}
Future<int> getBackupCount() {
return _backupRepository.getBackupCount();
Future<int> getBackupCount(String userId) {
return _backupRepository.getBackupCount(userId);
}
Future<void> manualBackup(List<LocalAsset> localAssets) async {
List<UploadTask> tasks = [];
for (final asset in localAssets) {
final task = await _getUploadTask(
asset,
group: kManualUploadGroup,
priority: 1, // High priority after upload motion photo part
);
if (task != null) {
tasks.add(task);
}
}
if (tasks.isNotEmpty) {
_uploadService.enqueueTasks(tasks);
}
}
Future<void> backup(
String userId,
void Function(EnqueueStatus status) onEnqueueTasks,
) async {
shouldCancel = false;
final candidates = await _backupRepository.getCandidates();
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
return;
}
@@ -146,7 +166,11 @@ class DriftBackupService {
}
}
Future<UploadTask?> _getUploadTask(LocalAsset asset) async {
Future<UploadTask?> _getUploadTask(
LocalAsset asset, {
String group = kBackupGroup,
int? priority,
}) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
return null;
@@ -192,7 +216,8 @@ class DriftBackupService {
originalFileName: originalFileName,
deviceAssetId: asset.id,
metadata: metadata,
group: kBackupGroup,
group: group,
priority: priority,
);
}

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:drift/drift.dart' as drift_db;
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -10,6 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
class BetaSyncSettings extends HookConsumerWidget {
const BetaSyncSettings({
@@ -69,6 +74,67 @@ class BetaSyncSettings extends HookConsumerWidget {
});
}
Future<void> exportDatabase() async {
try {
// WAL Checkpoint to ensure all changes are written to the database
await ref
.read(driftProvider)
.customStatement("pragma wal_checkpoint(truncate)");
final documentsDir = await getApplicationDocumentsDirectory();
final dbFile = File(path.join(documentsDir.path, 'immich.sqlite'));
if (!await dbFile.exists()) {
if (context.mounted) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text("Database file not found".t(context: context)),
),
);
}
return;
}
final timestamp = DateTime.now().millisecondsSinceEpoch;
final exportFile = File(
path.join(
documentsDir.path,
'immich_export_$timestamp.sqlite',
),
);
await dbFile.copy(exportFile.path);
await Share.shareXFiles(
[XFile(exportFile.path)],
text: 'Immich Database Export',
);
Future.delayed(const Duration(seconds: 30), () async {
if (await exportFile.exists()) {
await exportFile.delete();
}
});
if (context.mounted) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content:
Text("Database exported successfully".t(context: context)),
),
);
}
} catch (e) {
if (context.mounted) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content:
Text("Failed to export database: $e".t(context: context)),
),
);
}
}
}
return FutureBuilder<List<dynamic>>(
future: loadCounts(),
builder: (context, snapshot) {
@@ -232,6 +298,19 @@ class BetaSyncSettings extends HookConsumerWidget {
),
const SizedBox(height: 24),
_SectionHeaderText(text: "actions".t(context: context)),
ListTile(
title: Text(
"export_database".t(context: context),
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
"export_database_description".t(context: context),
),
leading: const Icon(Icons.download),
onTap: exportDatabase,
),
ListTile(
title: Text(
"reset_sqlite".t(context: context),

View File

@@ -479,6 +479,7 @@ Class | Method | HTTP request | Description
- [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
- [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)
- [SyncAssetV1](doc//SyncAssetV1.md)
- [SyncAuthUserV1](doc//SyncAuthUserV1.md)
- [SyncEntityType](doc//SyncEntityType.md)
- [SyncMemoryAssetDeleteV1](doc//SyncMemoryAssetDeleteV1.md)
- [SyncMemoryAssetV1](doc//SyncMemoryAssetV1.md)

View File

@@ -260,6 +260,7 @@ part 'model/sync_asset_exif_v1.dart';
part 'model/sync_asset_face_delete_v1.dart';
part 'model/sync_asset_face_v1.dart';
part 'model/sync_asset_v1.dart';
part 'model/sync_auth_user_v1.dart';
part 'model/sync_entity_type.dart';
part 'model/sync_memory_asset_delete_v1.dart';
part 'model/sync_memory_asset_v1.dart';

View File

@@ -576,6 +576,8 @@ class ApiClient {
return SyncAssetFaceV1.fromJson(value);
case 'SyncAssetV1':
return SyncAssetV1.fromJson(value);
case 'SyncAuthUserV1':
return SyncAuthUserV1.fromJson(value);
case 'SyncEntityType':
return SyncEntityTypeTypeTransformer().decode(value);
case 'SyncMemoryAssetDeleteV1':

View File

@@ -0,0 +1,215 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SyncAuthUserV1 {
/// Returns a new [SyncAuthUserV1] instance.
SyncAuthUserV1({
required this.avatarColor,
required this.deletedAt,
required this.email,
required this.hasProfileImage,
required this.id,
required this.isAdmin,
required this.name,
required this.oauthId,
required this.pinCode,
required this.profileChangedAt,
required this.quotaSizeInBytes,
required this.quotaUsageInBytes,
required this.storageLabel,
});
UserAvatarColor? avatarColor;
DateTime? deletedAt;
String email;
bool hasProfileImage;
String id;
bool isAdmin;
String name;
String oauthId;
String? pinCode;
DateTime profileChangedAt;
int? quotaSizeInBytes;
int quotaUsageInBytes;
String? storageLabel;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAuthUserV1 &&
other.avatarColor == avatarColor &&
other.deletedAt == deletedAt &&
other.email == email &&
other.hasProfileImage == hasProfileImage &&
other.id == id &&
other.isAdmin == isAdmin &&
other.name == name &&
other.oauthId == oauthId &&
other.pinCode == pinCode &&
other.profileChangedAt == profileChangedAt &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes &&
other.storageLabel == storageLabel;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(avatarColor == null ? 0 : avatarColor!.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) +
(email.hashCode) +
(hasProfileImage.hashCode) +
(id.hashCode) +
(isAdmin.hashCode) +
(name.hashCode) +
(oauthId.hashCode) +
(pinCode == null ? 0 : pinCode!.hashCode) +
(profileChangedAt.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(quotaUsageInBytes.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode);
@override
String toString() => 'SyncAuthUserV1[avatarColor=$avatarColor, deletedAt=$deletedAt, email=$email, hasProfileImage=$hasProfileImage, id=$id, isAdmin=$isAdmin, name=$name, oauthId=$oauthId, pinCode=$pinCode, profileChangedAt=$profileChangedAt, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.avatarColor != null) {
json[r'avatarColor'] = this.avatarColor;
} else {
// json[r'avatarColor'] = null;
}
if (this.deletedAt != null) {
json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String();
} else {
// json[r'deletedAt'] = null;
}
json[r'email'] = this.email;
json[r'hasProfileImage'] = this.hasProfileImage;
json[r'id'] = this.id;
json[r'isAdmin'] = this.isAdmin;
json[r'name'] = this.name;
json[r'oauthId'] = this.oauthId;
if (this.pinCode != null) {
json[r'pinCode'] = this.pinCode;
} else {
// json[r'pinCode'] = null;
}
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
if (this.quotaSizeInBytes != null) {
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
} else {
// json[r'quotaSizeInBytes'] = null;
}
json[r'quotaUsageInBytes'] = this.quotaUsageInBytes;
if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel;
} else {
// json[r'storageLabel'] = null;
}
return json;
}
/// Returns a new [SyncAuthUserV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAuthUserV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAuthUserV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAuthUserV1(
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
deletedAt: mapDateTime(json, r'deletedAt', r''),
email: mapValueOfType<String>(json, r'email')!,
hasProfileImage: mapValueOfType<bool>(json, r'hasProfileImage')!,
id: mapValueOfType<String>(json, r'id')!,
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
name: mapValueOfType<String>(json, r'name')!,
oauthId: mapValueOfType<String>(json, r'oauthId')!,
pinCode: mapValueOfType<String>(json, r'pinCode'),
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
quotaUsageInBytes: mapValueOfType<int>(json, r'quotaUsageInBytes')!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
);
}
return null;
}
static List<SyncAuthUserV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAuthUserV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAuthUserV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAuthUserV1> mapFromJson(dynamic json) {
final map = <String, SyncAuthUserV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAuthUserV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAuthUserV1-objects as value to a dart map
static Map<String, List<SyncAuthUserV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAuthUserV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAuthUserV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'avatarColor',
'deletedAt',
'email',
'hasProfileImage',
'id',
'isAdmin',
'name',
'oauthId',
'pinCode',
'profileChangedAt',
'quotaSizeInBytes',
'quotaUsageInBytes',
'storageLabel',
};
}

View File

@@ -23,6 +23,7 @@ class SyncEntityType {
String toJson() => value;
static const authUserV1 = SyncEntityType._(r'AuthUserV1');
static const userV1 = SyncEntityType._(r'UserV1');
static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1');
static const assetV1 = SyncEntityType._(r'AssetV1');
@@ -67,6 +68,7 @@ class SyncEntityType {
/// List of all possible values in this [enum][SyncEntityType].
static const values = <SyncEntityType>[
authUserV1,
userV1,
userDeleteV1,
assetV1,
@@ -146,6 +148,7 @@ class SyncEntityTypeTypeTransformer {
SyncEntityType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AuthUserV1': return SyncEntityType.authUserV1;
case r'UserV1': return SyncEntityType.userV1;
case r'UserDeleteV1': return SyncEntityType.userDeleteV1;
case r'AssetV1': return SyncEntityType.assetV1;

View File

@@ -30,6 +30,7 @@ class SyncRequestType {
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
static const assetsV1 = SyncRequestType._(r'AssetsV1');
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
static const memoriesV1 = SyncRequestType._(r'MemoriesV1');
static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1');
static const partnersV1 = SyncRequestType._(r'PartnersV1');
@@ -51,6 +52,7 @@ class SyncRequestType {
albumAssetExifsV1,
assetsV1,
assetExifsV1,
authUsersV1,
memoriesV1,
memoryToAssetsV1,
partnersV1,
@@ -107,6 +109,7 @@ class SyncRequestTypeTypeTransformer {
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
case r'AssetsV1': return SyncRequestType.assetsV1;
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
case r'MemoriesV1': return SyncRequestType.memoriesV1;
case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1;
case r'PartnersV1': return SyncRequestType.partnersV1;

View File

@@ -13,12 +13,15 @@ part of openapi.api;
class SyncUserV1 {
/// Returns a new [SyncUserV1] instance.
SyncUserV1({
required this.avatarColor,
required this.deletedAt,
required this.email,
required this.id,
required this.name,
});
UserAvatarColor? avatarColor;
DateTime? deletedAt;
String email;
@@ -29,6 +32,7 @@ class SyncUserV1 {
@override
bool operator ==(Object other) => identical(this, other) || other is SyncUserV1 &&
other.avatarColor == avatarColor &&
other.deletedAt == deletedAt &&
other.email == email &&
other.id == id &&
@@ -37,16 +41,22 @@ class SyncUserV1 {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(avatarColor == null ? 0 : avatarColor!.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) +
(email.hashCode) +
(id.hashCode) +
(name.hashCode);
@override
String toString() => 'SyncUserV1[deletedAt=$deletedAt, email=$email, id=$id, name=$name]';
String toString() => 'SyncUserV1[avatarColor=$avatarColor, deletedAt=$deletedAt, email=$email, id=$id, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.avatarColor != null) {
json[r'avatarColor'] = this.avatarColor;
} else {
// json[r'avatarColor'] = null;
}
if (this.deletedAt != null) {
json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String();
} else {
@@ -67,6 +77,7 @@ class SyncUserV1 {
final json = value.cast<String, dynamic>();
return SyncUserV1(
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
deletedAt: mapDateTime(json, r'deletedAt', r''),
email: mapValueOfType<String>(json, r'email')!,
id: mapValueOfType<String>(json, r'id')!,
@@ -118,6 +129,7 @@ class SyncUserV1 {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'avatarColor',
'deletedAt',
'email',
'id',

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ abstract final class SyncStreamStub {
email: "admin@admin",
id: "1",
name: "Admin",
avatarColor: null,
),
ack: "1",
);
@@ -19,6 +20,7 @@ abstract final class SyncStreamStub {
email: "user@user",
id: "5",
name: "User",
avatarColor: null,
),
ack: "5",
);