mirror of
https://github.com/immich-app/immich.git
synced 2025-01-12 15:32:36 +02:00
feature(mobile): sync assets, albums & users to local database on device (#1759)
* feature(mobile): sync assets, albums & users to local database on device * try to fix tests * move DB sync operations to new SyncService * clear db on user logout * fix reason for endless loading timeline * fix error when deleting album * fix thumbnail of device albums * add a few comments * fix Hive box not open in album service when loading local assets * adjust tests to int IDs * fix bug: show all albums when Recent is selected * update generated api * reworked Recents album isAll handling * guard against wrongly interleaved sync operations * fix: timeline asset ordering (sort asset state by created at) * fix: sort assets in albums by created at
This commit is contained in:
parent
8f11529a75
commit
8708867c1c
@ -142,6 +142,7 @@
|
|||||||
"library_page_sharing": "Sharing",
|
"library_page_sharing": "Sharing",
|
||||||
"library_page_sort_created": "Most recently created",
|
"library_page_sort_created": "Most recently created",
|
||||||
"library_page_sort_title": "Album title",
|
"library_page_sort_title": "Album title",
|
||||||
|
"library_page_device_albums": "Albums on Device",
|
||||||
"login_form_button_text": "Login",
|
"login_form_button_text": "Login",
|
||||||
"login_form_email_hint": "youremail@email.com",
|
"login_form_email_hint": "youremail@email.com",
|
||||||
"login_form_endpoint_hint": "http://your-server-ip:port/api",
|
"login_form_endpoint_hint": "http://your-server-ip:port/api",
|
||||||
|
@ -19,8 +19,12 @@ import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.pr
|
|||||||
import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart';
|
import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||||
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
|
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||||
@ -42,6 +46,7 @@ void main() async {
|
|||||||
await initApp();
|
await initApp();
|
||||||
final db = await loadDb();
|
final db = await loadDb();
|
||||||
await migrateHiveToStoreIfNecessary();
|
await migrateHiveToStoreIfNecessary();
|
||||||
|
await migrateJsonCacheIfNecessary();
|
||||||
runApp(getMainWidget(db));
|
runApp(getMainWidget(db));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +98,13 @@ Future<void> initApp() async {
|
|||||||
Future<Isar> loadDb() async {
|
Future<Isar> loadDb() async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
Isar db = await Isar.open(
|
Isar db = await Isar.open(
|
||||||
[StoreValueSchema],
|
[
|
||||||
|
StoreValueSchema,
|
||||||
|
ExifInfoSchema,
|
||||||
|
AssetSchema,
|
||||||
|
AlbumSchema,
|
||||||
|
UserSchema,
|
||||||
|
],
|
||||||
directory: dir.path,
|
directory: dir.path,
|
||||||
maxSizeMiB: 256,
|
maxSizeMiB: 256,
|
||||||
);
|
);
|
||||||
|
@ -1,37 +1,43 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||||
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
class AlbumNotifier extends StateNotifier<List<Album>> {
|
class AlbumNotifier extends StateNotifier<List<Album>> {
|
||||||
AlbumNotifier(this._albumService, this._albumCacheService) : super([]);
|
AlbumNotifier(this._albumService, this._db) : super([]);
|
||||||
final AlbumService _albumService;
|
final AlbumService _albumService;
|
||||||
final AlbumCacheService _albumCacheService;
|
final Isar _db;
|
||||||
|
|
||||||
void _cacheState() {
|
|
||||||
_albumCacheService.put(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> getAllAlbums() async {
|
Future<void> getAllAlbums() async {
|
||||||
if (await _albumCacheService.isValid() && state.isEmpty) {
|
final User me = Store.get(StoreKey.currentUser);
|
||||||
final albums = await _albumCacheService.get();
|
List<Album> albums = await _db.albums
|
||||||
if (albums != null) {
|
.filter()
|
||||||
state = albums;
|
.owner((q) => q.isarIdEqualTo(me.isarId))
|
||||||
}
|
.findAll();
|
||||||
}
|
if (!const ListEquality().equals(albums, state)) {
|
||||||
|
state = albums;
|
||||||
final albums = await _albumService.getAlbums(isShared: false);
|
}
|
||||||
|
await Future.wait([
|
||||||
if (albums != null) {
|
_albumService.refreshDeviceAlbums(),
|
||||||
|
_albumService.refreshRemoteAlbums(isShared: false),
|
||||||
|
]);
|
||||||
|
albums = await _db.albums
|
||||||
|
.filter()
|
||||||
|
.owner((q) => q.isarIdEqualTo(me.isarId))
|
||||||
|
.findAll();
|
||||||
|
if (!const ListEquality().equals(albums, state)) {
|
||||||
state = albums;
|
state = albums;
|
||||||
_cacheState();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteAlbum(Album album) {
|
Future<bool> deleteAlbum(Album album) async {
|
||||||
state = state.where((a) => a.id != album.id).toList();
|
state = state.where((a) => a.id != album.id).toList();
|
||||||
_cacheState();
|
return _albumService.deleteAlbum(album);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Album?> createAlbum(
|
Future<Album?> createAlbum(
|
||||||
@ -39,20 +45,16 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
|
|||||||
Set<Asset> assets,
|
Set<Asset> assets,
|
||||||
) async {
|
) async {
|
||||||
Album? album = await _albumService.createAlbum(albumTitle, assets, []);
|
Album? album = await _albumService.createAlbum(albumTitle, assets, []);
|
||||||
|
|
||||||
if (album != null) {
|
if (album != null) {
|
||||||
state = [...state, album];
|
state = [...state, album];
|
||||||
_cacheState();
|
|
||||||
|
|
||||||
return album;
|
|
||||||
}
|
}
|
||||||
return null;
|
return album;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) {
|
final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) {
|
||||||
return AlbumNotifier(
|
return AlbumNotifier(
|
||||||
ref.watch(albumServiceProvider),
|
ref.watch(albumServiceProvider),
|
||||||
ref.watch(albumCacheServiceProvider),
|
ref.watch(dbProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -58,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void addNewAssets(List<Asset> assets) {
|
void addNewAssets(Iterable<Asset> assets) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
selectedNewAssetsForAlbum: {
|
selectedNewAssetsForAlbum: {
|
||||||
...state.selectedNewAssetsForAlbum,
|
...state.selectedNewAssetsForAlbum,
|
||||||
|
@ -1,21 +1,18 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||||
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/models/user.dart';
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||||
SharedAlbumNotifier(this._albumService, this._sharedAlbumCacheService)
|
SharedAlbumNotifier(this._albumService, this._db) : super([]);
|
||||||
: super([]);
|
|
||||||
|
|
||||||
final AlbumService _albumService;
|
final AlbumService _albumService;
|
||||||
final SharedAlbumCacheService _sharedAlbumCacheService;
|
final Isar _db;
|
||||||
|
|
||||||
void _cacheState() {
|
|
||||||
_sharedAlbumCacheService.put(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Album?> createSharedAlbum(
|
Future<Album?> createSharedAlbum(
|
||||||
String albumName,
|
String albumName,
|
||||||
@ -23,7 +20,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
|||||||
Iterable<User> sharedUsers,
|
Iterable<User> sharedUsers,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
var newAlbum = await _albumService.createAlbum(
|
final Album? newAlbum = await _albumService.createAlbum(
|
||||||
albumName,
|
albumName,
|
||||||
assets,
|
assets,
|
||||||
sharedUsers,
|
sharedUsers,
|
||||||
@ -31,61 +28,44 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
|||||||
|
|
||||||
if (newAlbum != null) {
|
if (newAlbum != null) {
|
||||||
state = [...state, newAlbum];
|
state = [...state, newAlbum];
|
||||||
_cacheState();
|
return newAlbum;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newAlbum;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error createSharedAlbum ${e.toString()}");
|
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> getAllSharedAlbums() async {
|
Future<void> getAllSharedAlbums() async {
|
||||||
if (await _sharedAlbumCacheService.isValid() && state.isEmpty) {
|
var albums = await _db.albums.filter().sharedEqualTo(true).findAll();
|
||||||
final albums = await _sharedAlbumCacheService.get();
|
if (!const ListEquality().equals(albums, state)) {
|
||||||
if (albums != null) {
|
state = albums;
|
||||||
state = albums;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
await _albumService.refreshRemoteAlbums(isShared: true);
|
||||||
List<Album>? sharedAlbums = await _albumService.getAlbums(isShared: true);
|
albums = await _db.albums.filter().sharedEqualTo(true).findAll();
|
||||||
|
if (!const ListEquality().equals(albums, state)) {
|
||||||
if (sharedAlbums != null) {
|
state = albums;
|
||||||
state = sharedAlbums;
|
|
||||||
_cacheState();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteAlbum(Album album) {
|
Future<bool> deleteAlbum(Album album) {
|
||||||
state = state.where((a) => a.id != album.id).toList();
|
state = state.where((a) => a.id != album.id).toList();
|
||||||
_cacheState();
|
return _albumService.deleteAlbum(album);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> leaveAlbum(Album album) async {
|
Future<bool> leaveAlbum(Album album) async {
|
||||||
var res = await _albumService.leaveAlbum(album);
|
var res = await _albumService.leaveAlbum(album);
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
state = state.where((a) => a.id != album.id).toList();
|
await deleteAlbum(album);
|
||||||
_cacheState();
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> removeAssetFromAlbum(
|
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
|
||||||
Album album,
|
return _albumService.removeAssetFromAlbum(album, assets);
|
||||||
Iterable<Asset> assets,
|
|
||||||
) async {
|
|
||||||
var res = await _albumService.removeAssetFromAlbum(album, assets);
|
|
||||||
|
|
||||||
if (res) {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,13 +73,15 @@ final sharedAlbumProvider =
|
|||||||
StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) {
|
StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) {
|
||||||
return SharedAlbumNotifier(
|
return SharedAlbumNotifier(
|
||||||
ref.watch(albumServiceProvider),
|
ref.watch(albumServiceProvider),
|
||||||
ref.watch(sharedAlbumCacheServiceProvider),
|
ref.watch(dbProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final sharedAlbumDetailProvider =
|
final sharedAlbumDetailProvider =
|
||||||
FutureProvider.autoDispose.family<Album?, String>((ref, albumId) async {
|
FutureProvider.autoDispose.family<Album?, int>((ref, albumId) async {
|
||||||
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
|
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
|
||||||
|
|
||||||
return await sharedAlbumService.getAlbumDetail(albumId);
|
final Album? a = await sharedAlbumService.getAlbumDetail(albumId);
|
||||||
|
await a?.loadSortedAssets();
|
||||||
|
return a;
|
||||||
});
|
});
|
||||||
|
@ -3,8 +3,8 @@ import 'package:immich_mobile/shared/models/user.dart';
|
|||||||
import 'package:immich_mobile/shared/services/user.service.dart';
|
import 'package:immich_mobile/shared/services/user.service.dart';
|
||||||
|
|
||||||
final suggestedSharedUsersProvider =
|
final suggestedSharedUsersProvider =
|
||||||
FutureProvider.autoDispose<List<User>>((ref) async {
|
FutureProvider.autoDispose<List<User>>((ref) {
|
||||||
UserService userService = ref.watch(userServiceProvider);
|
UserService userService = ref.watch(userServiceProvider);
|
||||||
|
|
||||||
return await userService.getAllUsers(isAll: false) ?? [];
|
return userService.getUsersInDb();
|
||||||
});
|
});
|
||||||
|
@ -1,34 +1,129 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/shared/models/user.dart';
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/user.service.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
final albumServiceProvider = Provider(
|
final albumServiceProvider = Provider(
|
||||||
(ref) => AlbumService(
|
(ref) => AlbumService(
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
|
ref.watch(userServiceProvider),
|
||||||
|
ref.watch(backgroundServiceProvider),
|
||||||
|
ref.watch(syncServiceProvider),
|
||||||
|
ref.watch(dbProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class AlbumService {
|
class AlbumService {
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
|
final UserService _userService;
|
||||||
|
final BackgroundService _backgroundService;
|
||||||
|
final SyncService _syncService;
|
||||||
|
final Isar _db;
|
||||||
|
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||||
|
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
||||||
|
|
||||||
AlbumService(this._apiService);
|
AlbumService(
|
||||||
|
this._apiService,
|
||||||
|
this._userService,
|
||||||
|
this._backgroundService,
|
||||||
|
this._syncService,
|
||||||
|
this._db,
|
||||||
|
);
|
||||||
|
|
||||||
Future<List<Album>?> getAlbums({required bool isShared}) async {
|
/// Checks all selected device albums for changes of albums and their assets
|
||||||
try {
|
/// Updates the local database and returns `true` if there were any changes
|
||||||
final dto = await _apiService.albumApi
|
Future<bool> refreshDeviceAlbums() async {
|
||||||
.getAllAlbums(shared: isShared ? isShared : null);
|
if (!_localCompleter.isCompleted) {
|
||||||
return dto?.map(Album.remote).toList();
|
// guard against concurrent calls
|
||||||
} catch (e) {
|
return _localCompleter.future;
|
||||||
debugPrint("Error getAllSharedAlbum ${e.toString()}");
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
_localCompleter = Completer();
|
||||||
|
final Stopwatch sw = Stopwatch()..start();
|
||||||
|
bool changes = false;
|
||||||
|
try {
|
||||||
|
if (!await _backgroundService.hasAccess) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final HiveBackupAlbums? infos =
|
||||||
|
(await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox))
|
||||||
|
.get(backupInfoKey);
|
||||||
|
if (infos == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final List<AssetPathEntity> onDevice =
|
||||||
|
await PhotoManager.getAssetPathList(
|
||||||
|
hasAll: true,
|
||||||
|
filterOption: FilterOptionGroup(containsPathModified: true),
|
||||||
|
);
|
||||||
|
if (infos.excludedAlbumsIds.isNotEmpty) {
|
||||||
|
// remove all excluded albums
|
||||||
|
onDevice.removeWhere((e) => infos.excludedAlbumsIds.contains(e.id));
|
||||||
|
}
|
||||||
|
final hasAll = infos.selectedAlbumIds
|
||||||
|
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
|
||||||
|
.whereNotNull()
|
||||||
|
.any((a) => a.isAll);
|
||||||
|
if (hasAll) {
|
||||||
|
// remove the virtual "Recents" album and keep and individual albums
|
||||||
|
onDevice.removeWhere((e) => e.isAll);
|
||||||
|
} else {
|
||||||
|
// keep only the explicitly selected albums
|
||||||
|
onDevice.removeWhere((e) => !infos.selectedAlbumIds.contains(e.id));
|
||||||
|
}
|
||||||
|
changes = await _syncService.syncLocalAlbumAssetsToDb(onDevice);
|
||||||
|
} finally {
|
||||||
|
_localCompleter.complete(changes);
|
||||||
|
}
|
||||||
|
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks remote albums (owned if `isShared` is false) for changes,
|
||||||
|
/// updates the local database and returns `true` if there were any changes
|
||||||
|
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
|
||||||
|
if (!_remoteCompleter.isCompleted) {
|
||||||
|
// guard against concurrent calls
|
||||||
|
return _remoteCompleter.future;
|
||||||
|
}
|
||||||
|
_remoteCompleter = Completer();
|
||||||
|
final Stopwatch sw = Stopwatch()..start();
|
||||||
|
bool changes = false;
|
||||||
|
try {
|
||||||
|
await _userService.refreshUsers();
|
||||||
|
final List<AlbumResponseDto>? serverAlbums = await _apiService.albumApi
|
||||||
|
.getAllAlbums(shared: isShared ? true : null);
|
||||||
|
if (serverAlbums == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
changes = await _syncService.syncRemoteAlbumsToDb(
|
||||||
|
serverAlbums,
|
||||||
|
isShared: isShared,
|
||||||
|
loadDetails: (dto) async => dto.assetCount == dto.assets.length
|
||||||
|
? dto
|
||||||
|
: (await _apiService.albumApi.getAlbumInfo(dto.id)) ?? dto,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_remoteCompleter.complete(changes);
|
||||||
|
}
|
||||||
|
debugPrint("refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms");
|
||||||
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Album?> createAlbum(
|
Future<Album?> createAlbum(
|
||||||
@ -37,56 +132,51 @@ class AlbumService {
|
|||||||
Iterable<User> sharedUsers = const [],
|
Iterable<User> sharedUsers = const [],
|
||||||
]) async {
|
]) async {
|
||||||
try {
|
try {
|
||||||
final dto = await _apiService.albumApi.createAlbum(
|
AlbumResponseDto? remote = await _apiService.albumApi.createAlbum(
|
||||||
CreateAlbumDto(
|
CreateAlbumDto(
|
||||||
albumName: albumName,
|
albumName: albumName,
|
||||||
assetIds: assets.map((asset) => asset.remoteId!).toList(),
|
assetIds: assets.map((asset) => asset.remoteId!).toList(),
|
||||||
sharedWithUserIds: sharedUsers.map((e) => e.id).toList(),
|
sharedWithUserIds: sharedUsers.map((e) => e.id).toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return dto != null ? Album.remote(dto) : null;
|
if (remote != null) {
|
||||||
|
Album album = await Album.remote(remote);
|
||||||
|
await _db.writeTxn(() => _db.albums.store(album));
|
||||||
|
return album;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error createSharedAlbum ${e.toString()}");
|
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Creates names like Untitled, Untitled (1), Untitled (2), ...
|
* Creates names like Untitled, Untitled (1), Untitled (2), ...
|
||||||
*/
|
*/
|
||||||
String _getNextAlbumName(List<Album>? albums) {
|
Future<String> _getNextAlbumName() async {
|
||||||
const baseName = "Untitled";
|
const baseName = "Untitled";
|
||||||
|
for (int round = 0;; round++) {
|
||||||
|
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
|
||||||
|
|
||||||
if (albums != null) {
|
if (null ==
|
||||||
for (int round = 0; round < albums.length; round++) {
|
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
|
||||||
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
|
return proposedName;
|
||||||
|
|
||||||
if (albums.where((a) => a.name == proposedName).isEmpty) {
|
|
||||||
return proposedName;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return baseName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Album?> createAlbumWithGeneratedName(
|
Future<Album?> createAlbumWithGeneratedName(
|
||||||
Iterable<Asset> assets,
|
Iterable<Asset> assets,
|
||||||
) async {
|
) async {
|
||||||
return createAlbum(
|
return createAlbum(
|
||||||
_getNextAlbumName(await getAlbums(isShared: false)),
|
await _getNextAlbumName(),
|
||||||
assets,
|
assets,
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Album?> getAlbumDetail(String albumId) async {
|
Future<Album?> getAlbumDetail(int albumId) {
|
||||||
try {
|
return _db.albums.get(albumId);
|
||||||
final dto = await _apiService.albumApi.getAlbumInfo(albumId);
|
|
||||||
return dto != null ? Album.remote(dto) : null;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error [getAlbumDetail] ${e.toString()}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
|
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
|
||||||
@ -98,6 +188,10 @@ class AlbumService {
|
|||||||
album.remoteId!,
|
album.remoteId!,
|
||||||
AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()),
|
AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()),
|
||||||
);
|
);
|
||||||
|
if (result != null && result.successfullyAdded > 0) {
|
||||||
|
album.assets.addAll(assets);
|
||||||
|
await _db.writeTxn(() => album.assets.save());
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
|
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
|
||||||
@ -110,26 +204,53 @@ class AlbumService {
|
|||||||
Album album,
|
Album album,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
var result = await _apiService.albumApi.addUsersToAlbum(
|
final result = await _apiService.albumApi.addUsersToAlbum(
|
||||||
album.remoteId!,
|
album.remoteId!,
|
||||||
AddUsersDto(sharedUserIds: sharedUserIds),
|
AddUsersDto(sharedUserIds: sharedUserIds),
|
||||||
);
|
);
|
||||||
|
if (result != null) {
|
||||||
return result != null;
|
album.sharedUsers
|
||||||
|
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
|
||||||
|
await _db.writeTxn(() => album.sharedUsers.save());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
|
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> deleteAlbum(Album album) async {
|
Future<bool> deleteAlbum(Album album) async {
|
||||||
try {
|
try {
|
||||||
await _apiService.albumApi.deleteAlbum(album.remoteId!);
|
final userId = Store.get<User>(StoreKey.currentUser)!.isarId;
|
||||||
|
if (album.owner.value?.isarId == userId) {
|
||||||
|
await _apiService.albumApi.deleteAlbum(album.remoteId!);
|
||||||
|
}
|
||||||
|
if (album.shared) {
|
||||||
|
final foreignAssets =
|
||||||
|
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
|
||||||
|
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||||
|
final List<Album> albums =
|
||||||
|
await _db.albums.filter().sharedEqualTo(true).findAll();
|
||||||
|
final List<Asset> existing = [];
|
||||||
|
for (Album a in albums) {
|
||||||
|
existing.addAll(
|
||||||
|
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final List<int> idsToRemove =
|
||||||
|
_syncService.sharedAssetsToRemove(foreignAssets, existing);
|
||||||
|
if (idsToRemove.isNotEmpty) {
|
||||||
|
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("Error deleteAlbum ${e.toString()}");
|
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> leaveAlbum(Album album) async {
|
Future<bool> leaveAlbum(Album album) async {
|
||||||
@ -153,6 +274,8 @@ class AlbumService {
|
|||||||
assetIds: assets.map((e) => e.remoteId!).toList(growable: false),
|
assetIds: assets.map((e) => e.remoteId!).toList(growable: false),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
album.assets.removeAll(assets);
|
||||||
|
await _db.writeTxn(() => album.assets.update(unlink: assets));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -173,6 +296,7 @@ class AlbumService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
album.name = newAlbumTitle;
|
album.name = newAlbumTitle;
|
||||||
|
await _db.writeTxn(() => _db.albums.put(album));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -1,46 +1,23 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
import 'package:immich_mobile/shared/services/json_cache.dart';
|
import 'package:immich_mobile/shared/services/json_cache.dart';
|
||||||
|
|
||||||
class BaseAlbumCacheService extends JsonCache<List<Album>> {
|
@Deprecated("only kept to remove its files after migration")
|
||||||
BaseAlbumCacheService(super.cacheFileName);
|
class _BaseAlbumCacheService extends JsonCache<List<Album>> {
|
||||||
|
_BaseAlbumCacheService(super.cacheFileName);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void put(List<Album> data) {
|
void put(List<Album> data) {}
|
||||||
putRawData(data.map((e) => e.toJson()).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Album>?> get() async {
|
Future<List<Album>?> get() => Future.value(null);
|
||||||
try {
|
|
||||||
final mapList = await readRawData() as List<dynamic>;
|
|
||||||
|
|
||||||
final responseData =
|
|
||||||
mapList.map((e) => Album.fromJson(e)).whereNotNull().toList();
|
|
||||||
|
|
||||||
return responseData;
|
|
||||||
} catch (e) {
|
|
||||||
await invalidate();
|
|
||||||
debugPrint(e.toString());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class AlbumCacheService extends BaseAlbumCacheService {
|
@Deprecated("only kept to remove its files after migration")
|
||||||
|
class AlbumCacheService extends _BaseAlbumCacheService {
|
||||||
AlbumCacheService() : super("album_cache");
|
AlbumCacheService() : super("album_cache");
|
||||||
}
|
}
|
||||||
|
|
||||||
class SharedAlbumCacheService extends BaseAlbumCacheService {
|
@Deprecated("only kept to remove its files after migration")
|
||||||
|
class SharedAlbumCacheService extends _BaseAlbumCacheService {
|
||||||
SharedAlbumCacheService() : super("shared_album_cache");
|
SharedAlbumCacheService() : super("shared_album_cache");
|
||||||
}
|
}
|
||||||
|
|
||||||
final albumCacheServiceProvider = Provider(
|
|
||||||
(ref) => AlbumCacheService(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final sharedAlbumCacheServiceProvider = Provider(
|
|
||||||
(ref) => SharedAlbumCacheService(),
|
|
||||||
);
|
|
||||||
|
@ -25,7 +25,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final albums = ref.watch(albumProvider);
|
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||||
final albumService = ref.watch(albumServiceProvider);
|
final albumService = ref.watch(albumServiceProvider);
|
||||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||||
|
|
||||||
|
@ -1,11 +1,7 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hive/hive.dart';
|
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
|
|
||||||
class AlbumThumbnailCard extends StatelessWidget {
|
class AlbumThumbnailCard extends StatelessWidget {
|
||||||
final Function()? onTap;
|
final Function()? onTap;
|
||||||
@ -20,7 +16,6 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var box = Hive.box(userInfoBox);
|
|
||||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@ -42,21 +37,11 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildAlbumThumbnail() {
|
buildAlbumThumbnail() => ImmichImage(
|
||||||
return CachedNetworkImage(
|
album.thumbnail.value,
|
||||||
width: cardSize,
|
width: cardSize,
|
||||||
height: cardSize,
|
height: cardSize,
|
||||||
fit: BoxFit.cover,
|
);
|
||||||
fadeInDuration: const Duration(milliseconds: 200),
|
|
||||||
imageUrl: getAlbumThumbnailUrl(
|
|
||||||
album,
|
|
||||||
type: ThumbnailFormat.JPEG,
|
|
||||||
),
|
|
||||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
|
||||||
cacheKey:
|
|
||||||
getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@ -72,7 +57,7 @@ class AlbumThumbnailCard extends StatelessWidget {
|
|||||||
height: cardSize,
|
height: cardSize,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: album.albumThumbnailAssetId == null
|
child: album.thumbnail.value == null
|
||||||
? buildEmptyThumbnail()
|
? buildEmptyThumbnail()
|
||||||
: buildAlbumThumbnail(),
|
: buildAlbumThumbnail(),
|
||||||
),
|
),
|
||||||
|
@ -68,7 +68,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: album.albumThumbnailAssetId == null
|
child: album.thumbnail.value == null
|
||||||
? buildEmptyThumbnail()
|
? buildEmptyThumbnail()
|
||||||
: buildAlbumThumbnail(),
|
: buildAlbumThumbnail(),
|
||||||
),
|
),
|
||||||
|
@ -7,7 +7,6 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
|||||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
@ -35,19 +34,18 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
|||||||
void onDeleteAlbumPressed() async {
|
void onDeleteAlbumPressed() async {
|
||||||
ImmichLoadingOverlayController.appLoader.show();
|
ImmichLoadingOverlayController.appLoader.show();
|
||||||
|
|
||||||
bool isSuccess = await ref.watch(albumServiceProvider).deleteAlbum(album);
|
final bool success;
|
||||||
|
if (album.shared) {
|
||||||
if (isSuccess) {
|
success =
|
||||||
if (album.shared) {
|
await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
|
||||||
ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
|
AutoRouter.of(context)
|
||||||
AutoRouter.of(context)
|
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
|
||||||
} else {
|
|
||||||
ref.watch(albumProvider.notifier).deleteAlbum(album);
|
|
||||||
AutoRouter.of(context)
|
|
||||||
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
|
||||||
|
AutoRouter.of(context)
|
||||||
|
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
|
||||||
|
}
|
||||||
|
if (!success) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
msg: "album_viewer_appbar_share_err_delete".tr(),
|
msg: "album_viewer_appbar_share_err_delete".tr(),
|
||||||
@ -208,11 +206,12 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
|||||||
: null,
|
: null,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
if (album.isRemote)
|
||||||
splashRadius: 25,
|
IconButton(
|
||||||
onPressed: buildBottomSheet,
|
splashRadius: 25,
|
||||||
icon: const Icon(Icons.more_horiz_rounded),
|
onPressed: buildBottomSheet,
|
||||||
),
|
icon: const Icon(Icons.more_horiz_rounded),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
|
||||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
@ -22,7 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
|
||||||
final selectedAssetsInAlbumViewer =
|
final selectedAssetsInAlbumViewer =
|
||||||
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
|
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
|
||||||
final isMultiSelectionEnable =
|
final isMultiSelectionEnable =
|
||||||
@ -88,7 +86,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||||||
bottom: 5,
|
bottom: 5,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
asset.isRemote
|
asset.isRemote
|
||||||
? (deviceId == asset.deviceId
|
? (asset.isLocal
|
||||||
? Icons.cloud_done_outlined
|
? Icons.cloud_done_outlined
|
||||||
: Icons.cloud_outlined)
|
: Icons.cloud_outlined)
|
||||||
: Icons.cloud_off_outlined,
|
: Icons.cloud_off_outlined,
|
||||||
|
@ -25,7 +25,7 @@ import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegat
|
|||||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||||
|
|
||||||
class AlbumViewerPage extends HookConsumerWidget {
|
class AlbumViewerPage extends HookConsumerWidget {
|
||||||
final String albumId;
|
final int albumId;
|
||||||
|
|
||||||
const AlbumViewerPage({Key? key, required this.albumId}) : super(key: key);
|
const AlbumViewerPage({Key? key, required this.albumId}) : super(key: key);
|
||||||
|
|
||||||
@ -101,7 +101,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
Widget buildTitle(Album album) {
|
Widget buildTitle(Album album) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
|
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
|
||||||
child: userId == album.ownerId
|
child: userId == album.ownerId && album.isRemote
|
||||||
? AlbumViewerEditableTitle(
|
? AlbumViewerEditableTitle(
|
||||||
album: album,
|
album: album,
|
||||||
titleFocusNode: titleFocusNode,
|
titleFocusNode: titleFocusNode,
|
||||||
@ -122,9 +122,10 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
Widget buildAlbumDateRange(Album album) {
|
Widget buildAlbumDateRange(Album album) {
|
||||||
final DateTime startDate = album.assets.first.fileCreatedAt;
|
final DateTime startDate = album.assets.first.fileCreatedAt;
|
||||||
final DateTime endDate = album.assets.last.fileCreatedAt; //Need default.
|
final DateTime endDate = album.assets.last.fileCreatedAt; //Need default.
|
||||||
final String startDateText =
|
final String startDateText = (startDate.year == endDate.year
|
||||||
(startDate.year == endDate.year ? DateFormat.MMMd() : DateFormat.yMMMd())
|
? DateFormat.MMMd()
|
||||||
.format(startDate);
|
: DateFormat.yMMMd())
|
||||||
|
.format(startDate);
|
||||||
final String endDateText = DateFormat.yMMMd().format(endDate);
|
final String endDateText = DateFormat.yMMMd().format(endDate);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@ -188,7 +189,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
final bool showStorageIndicator =
|
final bool showStorageIndicator =
|
||||||
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
||||||
|
|
||||||
if (album.assets.isNotEmpty) {
|
if (album.sortedAssets.isNotEmpty) {
|
||||||
return SliverPadding(
|
return SliverPadding(
|
||||||
padding: const EdgeInsets.only(top: 10.0),
|
padding: const EdgeInsets.only(top: 10.0),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
@ -201,8 +202,8 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(BuildContext context, int index) {
|
(BuildContext context, int index) {
|
||||||
return AlbumViewerThumbnail(
|
return AlbumViewerThumbnail(
|
||||||
asset: album.assets[index],
|
asset: album.sortedAssets[index],
|
||||||
assetList: album.assets,
|
assetList: album.sortedAssets,
|
||||||
showStorageIndicator: showStorageIndicator,
|
showStorageIndicator: showStorageIndicator,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -267,17 +268,18 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
buildHeader(album),
|
buildHeader(album),
|
||||||
SliverPersistentHeader(
|
if (album.isRemote)
|
||||||
pinned: true,
|
SliverPersistentHeader(
|
||||||
delegate: ImmichSliverPersistentAppBarDelegate(
|
pinned: true,
|
||||||
minHeight: 50,
|
delegate: ImmichSliverPersistentAppBarDelegate(
|
||||||
maxHeight: 50,
|
minHeight: 50,
|
||||||
child: Container(
|
maxHeight: 50,
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
child: Container(
|
||||||
child: buildControlButton(album),
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
child: buildControlButton(album),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
SliverSafeArea(
|
SliverSafeArea(
|
||||||
sliver: buildImageGrid(album),
|
sliver: buildImageGrid(album),
|
||||||
),
|
),
|
||||||
|
@ -44,9 +44,13 @@ class LibraryPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
List<Album> sortedAlbums() {
|
List<Album> sortedAlbums() {
|
||||||
if (selectedAlbumSortOrder.value == 0) {
|
if (selectedAlbumSortOrder.value == 0) {
|
||||||
return albums.sortedBy((album) => album.createdAt).reversed.toList();
|
return albums
|
||||||
|
.where((a) => a.isRemote)
|
||||||
|
.sortedBy((album) => album.createdAt)
|
||||||
|
.reversed
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
return albums.sortedBy((album) => album.name);
|
return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildSortButton() {
|
Widget buildSortButton() {
|
||||||
@ -194,6 +198,8 @@ class LibraryPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
final sorted = sortedAlbums();
|
final sorted = sortedAlbums();
|
||||||
|
|
||||||
|
final local = albums.where((a) => a.isLocal).toList();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: buildAppBar(),
|
appBar: buildAppBar(),
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
@ -270,6 +276,47 @@ class LibraryPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 12.0,
|
||||||
|
left: 12.0,
|
||||||
|
right: 12.0,
|
||||||
|
bottom: 20.0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'library_page_device_albums',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
).tr(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 250,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: .7,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
childCount: local.length,
|
||||||
|
(context, index) => AlbumThumbnailCard(
|
||||||
|
album: local[index],
|
||||||
|
onTap: () => AutoRouter.of(context).push(
|
||||||
|
AlbumViewerRoute(
|
||||||
|
albumId: local[index].id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -1,23 +1,19 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hive/hive.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
|
||||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||||
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/models/album.dart';
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||||
|
|
||||||
class SharingPage extends HookConsumerWidget {
|
class SharingPage extends HookConsumerWidget {
|
||||||
const SharingPage({Key? key}) : super(key: key);
|
const SharingPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
var box = Hive.box(userInfoBox);
|
|
||||||
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
|
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@ -39,16 +35,10 @@ class SharingPage extends HookConsumerWidget {
|
|||||||
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||||
leading: ClipRRect(
|
leading: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: CachedNetworkImage(
|
child: ImmichImage(
|
||||||
|
album.thumbnail.value,
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
fit: BoxFit.cover,
|
|
||||||
imageUrl: getAlbumThumbnailUrl(album),
|
|
||||||
cacheKey: getAlbumThumbNailCacheKey(album),
|
|
||||||
httpHeaders: {
|
|
||||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
|
||||||
},
|
|
||||||
fadeInDuration: const Duration(milliseconds: 200),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
|
@ -14,10 +14,14 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
const ExifBottomSheet({Key? key, required this.assetDetail})
|
const ExifBottomSheet({Key? key, required this.assetDetail})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
bool get showMap => assetDetail.latitude != null && assetDetail.longitude != null;
|
bool get showMap =>
|
||||||
|
assetDetail.exifInfo?.latitude != null &&
|
||||||
|
assetDetail.exifInfo?.longitude != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final ExifInfo? exifInfo = assetDetail.exifInfo;
|
||||||
|
|
||||||
buildMap() {
|
buildMap() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
@ -33,8 +37,8 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
interactiveFlags: InteractiveFlag.none,
|
interactiveFlags: InteractiveFlag.none,
|
||||||
center: LatLng(
|
center: LatLng(
|
||||||
assetDetail.latitude ?? 0,
|
exifInfo?.latitude ?? 0,
|
||||||
assetDetail.longitude ?? 0,
|
exifInfo?.longitude ?? 0,
|
||||||
),
|
),
|
||||||
zoom: 16.0,
|
zoom: 16.0,
|
||||||
),
|
),
|
||||||
@ -55,8 +59,8 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
Marker(
|
Marker(
|
||||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||||
point: LatLng(
|
point: LatLng(
|
||||||
assetDetail.latitude ?? 0,
|
exifInfo?.latitude ?? 0,
|
||||||
assetDetail.longitude ?? 0,
|
exifInfo?.longitude ?? 0,
|
||||||
),
|
),
|
||||||
builder: (ctx) => const Image(
|
builder: (ctx) => const Image(
|
||||||
image: AssetImage('assets/location-pin.png'),
|
image: AssetImage('assets/location-pin.png'),
|
||||||
@ -74,8 +78,6 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
|
|
||||||
final textColor = Theme.of(context).primaryColor;
|
final textColor = Theme.of(context).primaryColor;
|
||||||
|
|
||||||
ExifInfo? exifInfo = assetDetail.exifInfo;
|
|
||||||
|
|
||||||
buildLocationText() {
|
buildLocationText() {
|
||||||
return Text(
|
return Text(
|
||||||
"${exifInfo?.city}, ${exifInfo?.state}",
|
"${exifInfo?.city}, ${exifInfo?.state}",
|
||||||
@ -134,7 +136,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||||||
exifInfo.state != null)
|
exifInfo.state != null)
|
||||||
buildLocationText(),
|
buildLocationText(),
|
||||||
Text(
|
Text(
|
||||||
"${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}",
|
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
|
||||||
style: const TextStyle(fontSize: 12),
|
style: const TextStyle(fontSize: 12),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
@ -75,15 +75,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
ref.watch(favoriteProvider.notifier).toggleFavorite(asset);
|
ref.watch(favoriteProvider.notifier).toggleFavorite(asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAssetExif() async {
|
void getAssetExif() async {
|
||||||
if (assetList[indexOfAsset.value].isRemote) {
|
assetDetail = assetList[indexOfAsset.value];
|
||||||
assetDetail = await ref
|
assetDetail = await ref
|
||||||
.watch(assetServiceProvider)
|
.watch(assetServiceProvider)
|
||||||
.getAssetById(assetList[indexOfAsset.value].id);
|
.loadExif(assetList[indexOfAsset.value]);
|
||||||
} else {
|
|
||||||
// TODO local exif parsing?
|
|
||||||
assetDetail = assetList[indexOfAsset.value];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thumbnail image of a remote asset. Required asset.isRemote
|
/// Thumbnail image of a remote asset. Required asset.isRemote
|
||||||
|
@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
|
|
||||||
class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
|
class FavoriteSelectionNotifier extends StateNotifier<Set<int>> {
|
||||||
FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) {
|
FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) {
|
||||||
state = assetsState.allAssets
|
state = assetsState.allAssets
|
||||||
.where((asset) => asset.isFavorite)
|
.where((asset) => asset.isFavorite)
|
||||||
@ -13,7 +13,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
|
|||||||
final AssetsState assetsState;
|
final AssetsState assetsState;
|
||||||
final AssetNotifier assetNotifier;
|
final AssetNotifier assetNotifier;
|
||||||
|
|
||||||
void _setFavoriteForAssetId(String id, bool favorite) {
|
void _setFavoriteForAssetId(int id, bool favorite) {
|
||||||
if (!favorite) {
|
if (!favorite) {
|
||||||
state = state.difference({id});
|
state = state.difference({id});
|
||||||
} else {
|
} else {
|
||||||
@ -21,7 +21,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isFavorite(String id) {
|
bool _isFavorite(int id) {
|
||||||
return state.contains(id);
|
return state.contains(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,22 +38,22 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
|
|||||||
|
|
||||||
Future<void> addToFavorites(Iterable<Asset> assets) {
|
Future<void> addToFavorites(Iterable<Asset> assets) {
|
||||||
state = state.union(assets.map((a) => a.id).toSet());
|
state = state.union(assets.map((a) => a.id).toSet());
|
||||||
final futures = assets.map((a) =>
|
final futures = assets.map(
|
||||||
assetNotifier.toggleFavorite(
|
(a) => assetNotifier.toggleFavorite(
|
||||||
a,
|
a,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Future.wait(futures);
|
return Future.wait(futures);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final favoriteProvider =
|
final favoriteProvider =
|
||||||
StateNotifierProvider<FavoriteSelectionNotifier, Set<String>>((ref) {
|
StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) {
|
||||||
return FavoriteSelectionNotifier(
|
return FavoriteSelectionNotifier(
|
||||||
ref.watch(assetProvider),
|
ref.watch(assetProvider),
|
||||||
ref.watch(assetProvider.notifier),
|
ref.watch(assetProvider.notifier),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
|||||||
ItemPositionsListener.create();
|
ItemPositionsListener.create();
|
||||||
|
|
||||||
bool _scrolling = false;
|
bool _scrolling = false;
|
||||||
final Set<String> _selectedAssets = HashSet();
|
final Set<int> _selectedAssets = HashSet();
|
||||||
|
|
||||||
Set<Asset> _getSelectedAssets() {
|
Set<Asset> _getSelectedAssets() {
|
||||||
return _selectedAssets
|
return _selectedAssets
|
||||||
|
@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||||
@ -32,8 +31,6 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
|
||||||
|
|
||||||
Widget buildSelectionIcon(Asset asset) {
|
Widget buildSelectionIcon(Asset asset) {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return Icon(
|
return Icon(
|
||||||
@ -103,7 +100,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||||||
bottom: 5,
|
bottom: 5,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
asset.isRemote
|
asset.isRemote
|
||||||
? (deviceId == asset.deviceId
|
? (asset.isLocal
|
||||||
? Icons.cloud_done_outlined
|
? Icons.cloud_done_outlined
|
||||||
: Icons.cloud_outlined)
|
: Icons.cloud_outlined)
|
||||||
: Icons.cloud_off_outlined,
|
: Icons.cloud_off_outlined,
|
||||||
|
@ -38,7 +38,7 @@ class HomePage extends HookConsumerWidget {
|
|||||||
final selectionEnabledHook = useState(false);
|
final selectionEnabledHook = useState(false);
|
||||||
|
|
||||||
final selection = useState(<Asset>{});
|
final selection = useState(<Asset>{});
|
||||||
final albums = ref.watch(albumProvider);
|
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||||
final albumService = ref.watch(albumServiceProvider);
|
final albumService = ref.watch(albumServiceProvider);
|
||||||
|
|
||||||
|
@ -3,15 +3,15 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
|
|
||||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||||
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||||
@ -19,9 +19,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
this._deviceInfoService,
|
this._deviceInfoService,
|
||||||
this._backupService,
|
this._backupService,
|
||||||
this._apiService,
|
this._apiService,
|
||||||
this._assetCacheService,
|
|
||||||
this._albumCacheService,
|
|
||||||
this._sharedAlbumCacheService,
|
|
||||||
) : super(
|
) : super(
|
||||||
AuthenticationState(
|
AuthenticationState(
|
||||||
deviceId: "",
|
deviceId: "",
|
||||||
@ -48,9 +45,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
final DeviceInfoService _deviceInfoService;
|
final DeviceInfoService _deviceInfoService;
|
||||||
final BackupService _backupService;
|
final BackupService _backupService;
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
final AssetCacheService _assetCacheService;
|
|
||||||
final AlbumCacheService _albumCacheService;
|
|
||||||
final SharedAlbumCacheService _sharedAlbumCacheService;
|
|
||||||
|
|
||||||
Future<bool> login(
|
Future<bool> login(
|
||||||
String email,
|
String email,
|
||||||
@ -98,9 +92,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
Hive.box(userInfoBox).delete(accessTokenKey),
|
Hive.box(userInfoBox).delete(accessTokenKey),
|
||||||
Store.delete(StoreKey.assetETag),
|
Store.delete(StoreKey.assetETag),
|
||||||
Store.delete(StoreKey.userRemoteId),
|
Store.delete(StoreKey.userRemoteId),
|
||||||
_assetCacheService.invalidate(),
|
Store.delete(StoreKey.currentUser),
|
||||||
_albumCacheService.invalidate(),
|
|
||||||
_sharedAlbumCacheService.invalidate(),
|
|
||||||
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey)
|
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -160,7 +152,10 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
var deviceInfo = await _deviceInfoService.getDeviceInfo();
|
var deviceInfo = await _deviceInfoService.getDeviceInfo();
|
||||||
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
|
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
|
||||||
userInfoHiveBox.put(accessTokenKey, accessToken);
|
userInfoHiveBox.put(accessTokenKey, accessToken);
|
||||||
|
Store.put(StoreKey.deviceId, deviceInfo["deviceId"]);
|
||||||
|
Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"]));
|
||||||
Store.put(StoreKey.userRemoteId, userResponseDto.id);
|
Store.put(StoreKey.userRemoteId, userResponseDto.id);
|
||||||
|
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
@ -218,8 +213,5 @@ final authenticationProvider =
|
|||||||
ref.watch(deviceInfoServiceProvider),
|
ref.watch(deviceInfoServiceProvider),
|
||||||
ref.watch(backupServiceProvider),
|
ref.watch(backupServiceProvider),
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
ref.watch(assetCacheServiceProvider),
|
|
||||||
ref.watch(albumCacheServiceProvider),
|
|
||||||
ref.watch(sharedAlbumCacheServiceProvider),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
|
|
||||||
class SearchResultPageState {
|
class SearchResultPageState {
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
@ -31,34 +28,6 @@ class SearchResultPageState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
|
||||||
return {
|
|
||||||
'isLoading': isLoading,
|
|
||||||
'isSuccess': isSuccess,
|
|
||||||
'isError': isError,
|
|
||||||
'searchResult': searchResult.map((x) => x.toJson()).toList(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
factory SearchResultPageState.fromMap(Map<String, dynamic> map) {
|
|
||||||
return SearchResultPageState(
|
|
||||||
isLoading: map['isLoading'] ?? false,
|
|
||||||
isSuccess: map['isSuccess'] ?? false,
|
|
||||||
isError: map['isError'] ?? false,
|
|
||||||
searchResult: List.from(
|
|
||||||
map['searchResult']
|
|
||||||
.map(AssetResponseDto.fromJson)
|
|
||||||
.where((e) => e != null)
|
|
||||||
.map(Asset.remote),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String toJson() => json.encode(toMap());
|
|
||||||
|
|
||||||
factory SearchResultPageState.fromJson(String source) =>
|
|
||||||
SearchResultPageState.fromMap(json.decode(source));
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)';
|
return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)';
|
||||||
|
@ -2,19 +2,23 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
final searchServiceProvider = Provider(
|
final searchServiceProvider = Provider(
|
||||||
(ref) => SearchService(
|
(ref) => SearchService(
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
|
ref.watch(dbProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class SearchService {
|
class SearchService {
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
|
final Isar _db;
|
||||||
|
|
||||||
SearchService(this._apiService);
|
SearchService(this._apiService, this._db);
|
||||||
|
|
||||||
Future<List<String>?> getUserSuggestedSearchTerms() async {
|
Future<List<String>?> getUserSuggestedSearchTerms() async {
|
||||||
try {
|
try {
|
||||||
@ -26,13 +30,15 @@ class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Asset>?> searchAsset(String searchTerm) async {
|
Future<List<Asset>?> searchAsset(String searchTerm) async {
|
||||||
|
// TODO search in local DB: 1. when offline, 2. to find local assets
|
||||||
try {
|
try {
|
||||||
final List<AssetResponseDto>? results = await _apiService.assetApi
|
final List<AssetResponseDto>? results = await _apiService.assetApi
|
||||||
.searchAsset(SearchAssetDto(searchTerm: searchTerm));
|
.searchAsset(SearchAssetDto(searchTerm: searchTerm));
|
||||||
if (results == null) {
|
if (results == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return results.map((e) => Asset.remote(e)).toList();
|
// TODO local DB might be out of date; add assets not yet in DB?
|
||||||
|
return _db.assets.getAllByRemoteId(results.map((e) => e.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[ERROR] [searchAsset] ${e.toString()}");
|
debugPrint("[ERROR] [searchAsset] ${e.toString()}");
|
||||||
return null;
|
return null;
|
||||||
|
@ -698,7 +698,7 @@ class SelectUserForSharingRoute extends PageRouteInfo<void> {
|
|||||||
class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
|
class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
|
||||||
AlbumViewerRoute({
|
AlbumViewerRoute({
|
||||||
Key? key,
|
Key? key,
|
||||||
required String albumId,
|
required int albumId,
|
||||||
}) : super(
|
}) : super(
|
||||||
AlbumViewerRoute.name,
|
AlbumViewerRoute.name,
|
||||||
path: '/album-viewer-page',
|
path: '/album-viewer-page',
|
||||||
@ -719,7 +719,7 @@ class AlbumViewerRouteArgs {
|
|||||||
|
|
||||||
final Key? key;
|
final Key? key;
|
||||||
|
|
||||||
final String albumId;
|
final int albumId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
|
@ -1,132 +1,153 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/shared/models/user.dart';
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
part 'album.g.dart';
|
||||||
|
|
||||||
|
@Collection(inheritance: false)
|
||||||
class Album {
|
class Album {
|
||||||
Album.remote(AlbumResponseDto dto)
|
@protected
|
||||||
: remoteId = dto.id,
|
|
||||||
name = dto.albumName,
|
|
||||||
createdAt = DateTime.parse(dto.createdAt),
|
|
||||||
// TODO add modifiedAt to server
|
|
||||||
modifiedAt = DateTime.parse(dto.createdAt),
|
|
||||||
shared = dto.shared,
|
|
||||||
ownerId = dto.ownerId,
|
|
||||||
albumThumbnailAssetId = dto.albumThumbnailAssetId,
|
|
||||||
assetCount = dto.assetCount,
|
|
||||||
sharedUsers = dto.sharedUsers.map((e) => User.fromDto(e)).toList(),
|
|
||||||
assets = dto.assets.map(Asset.remote).toList();
|
|
||||||
|
|
||||||
Album({
|
Album({
|
||||||
this.remoteId,
|
this.remoteId,
|
||||||
this.localId,
|
this.localId,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.ownerId,
|
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.modifiedAt,
|
required this.modifiedAt,
|
||||||
required this.shared,
|
required this.shared,
|
||||||
required this.assetCount,
|
|
||||||
this.albumThumbnailAssetId,
|
|
||||||
this.sharedUsers = const [],
|
|
||||||
this.assets = const [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Id id = Isar.autoIncrement;
|
||||||
|
@Index(unique: false, replace: false, type: IndexType.hash)
|
||||||
String? remoteId;
|
String? remoteId;
|
||||||
|
@Index(unique: false, replace: false, type: IndexType.hash)
|
||||||
String? localId;
|
String? localId;
|
||||||
String name;
|
String name;
|
||||||
String ownerId;
|
|
||||||
DateTime createdAt;
|
DateTime createdAt;
|
||||||
DateTime modifiedAt;
|
DateTime modifiedAt;
|
||||||
bool shared;
|
bool shared;
|
||||||
String? albumThumbnailAssetId;
|
final IsarLink<User> owner = IsarLink<User>();
|
||||||
int assetCount;
|
final IsarLink<Asset> thumbnail = IsarLink<Asset>();
|
||||||
List<User> sharedUsers = const [];
|
final IsarLinks<User> sharedUsers = IsarLinks<User>();
|
||||||
List<Asset> assets = const [];
|
final IsarLinks<Asset> assets = IsarLinks<Asset>();
|
||||||
|
|
||||||
|
List<Asset> _sortedAssets = [];
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
List<Asset> get sortedAssets => _sortedAssets;
|
||||||
|
|
||||||
|
@ignore
|
||||||
bool get isRemote => remoteId != null;
|
bool get isRemote => remoteId != null;
|
||||||
|
|
||||||
|
@ignore
|
||||||
bool get isLocal => localId != null;
|
bool get isLocal => localId != null;
|
||||||
|
|
||||||
String get id => isRemote ? remoteId! : localId!;
|
@ignore
|
||||||
|
int get assetCount => assets.length;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
String? get ownerId => owner.value?.id;
|
||||||
|
|
||||||
|
Future<void> loadSortedAssets() async {
|
||||||
|
_sortedAssets = await assets.filter().sortByFileCreatedAt().findAll();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(other) {
|
bool operator ==(other) {
|
||||||
if (other is! Album) return false;
|
if (other is! Album) return false;
|
||||||
return remoteId == other.remoteId &&
|
return id == other.id &&
|
||||||
|
remoteId == other.remoteId &&
|
||||||
localId == other.localId &&
|
localId == other.localId &&
|
||||||
name == other.name &&
|
name == other.name &&
|
||||||
createdAt == other.createdAt &&
|
createdAt == other.createdAt &&
|
||||||
modifiedAt == other.modifiedAt &&
|
modifiedAt == other.modifiedAt &&
|
||||||
shared == other.shared &&
|
shared == other.shared &&
|
||||||
ownerId == other.ownerId &&
|
owner.value == other.owner.value &&
|
||||||
albumThumbnailAssetId == other.albumThumbnailAssetId;
|
thumbnail.value == other.thumbnail.value &&
|
||||||
|
sharedUsers.length == other.sharedUsers.length &&
|
||||||
|
assets.length == other.assets.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ignore
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
|
id.hashCode ^
|
||||||
remoteId.hashCode ^
|
remoteId.hashCode ^
|
||||||
localId.hashCode ^
|
localId.hashCode ^
|
||||||
name.hashCode ^
|
name.hashCode ^
|
||||||
createdAt.hashCode ^
|
createdAt.hashCode ^
|
||||||
modifiedAt.hashCode ^
|
modifiedAt.hashCode ^
|
||||||
shared.hashCode ^
|
shared.hashCode ^
|
||||||
ownerId.hashCode ^
|
owner.value.hashCode ^
|
||||||
albumThumbnailAssetId.hashCode;
|
thumbnail.value.hashCode ^
|
||||||
|
sharedUsers.length.hashCode ^
|
||||||
|
assets.length.hashCode;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
static Album local(AssetPathEntity ape) {
|
||||||
final json = <String, dynamic>{};
|
final Album a = Album(
|
||||||
json["remoteId"] = remoteId;
|
name: ape.name,
|
||||||
json["localId"] = localId;
|
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
||||||
json["name"] = name;
|
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
||||||
json["ownerId"] = ownerId;
|
shared: false,
|
||||||
json["createdAt"] = createdAt.millisecondsSinceEpoch;
|
);
|
||||||
json["modifiedAt"] = modifiedAt.millisecondsSinceEpoch;
|
a.owner.value = Store.get(StoreKey.currentUser);
|
||||||
json["shared"] = shared;
|
a.localId = ape.id;
|
||||||
json["albumThumbnailAssetId"] = albumThumbnailAssetId;
|
return a;
|
||||||
json["assetCount"] = assetCount;
|
|
||||||
json["sharedUsers"] = sharedUsers;
|
|
||||||
json["assets"] = assets;
|
|
||||||
return json;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Album? fromJson(dynamic value) {
|
static Future<Album> remote(AlbumResponseDto dto) async {
|
||||||
if (value is Map) {
|
final Isar db = Isar.getInstance()!;
|
||||||
final json = value.cast<String, dynamic>();
|
final Album a = Album(
|
||||||
return Album(
|
remoteId: dto.id,
|
||||||
remoteId: json["remoteId"],
|
name: dto.albumName,
|
||||||
localId: json["localId"],
|
createdAt: DateTime.parse(dto.createdAt),
|
||||||
name: json["name"],
|
modifiedAt: DateTime.parse(dto.updatedAt),
|
||||||
ownerId: json["ownerId"],
|
shared: dto.shared,
|
||||||
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
);
|
||||||
json["createdAt"],
|
a.owner.value = await db.users.getById(dto.ownerId);
|
||||||
isUtc: true,
|
if (dto.albumThumbnailAssetId != null) {
|
||||||
),
|
a.thumbnail.value = await db.assets
|
||||||
modifiedAt: DateTime.fromMillisecondsSinceEpoch(
|
.where()
|
||||||
json["modifiedAt"],
|
.remoteIdEqualTo(dto.albumThumbnailAssetId)
|
||||||
isUtc: true,
|
.findFirst();
|
||||||
),
|
|
||||||
shared: json["shared"],
|
|
||||||
albumThumbnailAssetId: json["albumThumbnailAssetId"],
|
|
||||||
assetCount: json["assetCount"],
|
|
||||||
sharedUsers: _listFromJson<User>(json["sharedUsers"], User.fromJson),
|
|
||||||
assets: _listFromJson<Asset>(json["assets"], Asset.fromJson),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return null;
|
if (dto.sharedUsers.isNotEmpty) {
|
||||||
|
final users = await db.users
|
||||||
|
.getAllById(dto.sharedUsers.map((e) => e.id).toList(growable: false));
|
||||||
|
a.sharedUsers.addAll(users.cast());
|
||||||
|
}
|
||||||
|
if (dto.assets.isNotEmpty) {
|
||||||
|
final assets =
|
||||||
|
await db.assets.getAllByRemoteId(dto.assets.map((e) => e.id));
|
||||||
|
a.assets.addAll(assets);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<T> _listFromJson<T>(
|
extension AssetsHelper on IsarCollection<Album> {
|
||||||
dynamic json,
|
Future<void> store(Album a) async {
|
||||||
T? Function(dynamic) fromJson,
|
await put(a);
|
||||||
) {
|
await a.owner.save();
|
||||||
final result = <T>[];
|
await a.thumbnail.save();
|
||||||
if (json is List && json.isNotEmpty) {
|
await a.sharedUsers.save();
|
||||||
for (final entry in json) {
|
await a.assets.save();
|
||||||
final value = fromJson(entry);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result;
|
}
|
||||||
|
|
||||||
|
extension AssetPathEntityHelper on AssetPathEntity {
|
||||||
|
Future<List<Asset>> getAssets({
|
||||||
|
int start = 0,
|
||||||
|
int end = 0x7fffffffffffffff,
|
||||||
|
}) async {
|
||||||
|
final assetEntities = await getAssetListRange(start: start, end: end);
|
||||||
|
return assetEntities.map(Asset.local).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AlbumResponseDtoHelper on AlbumResponseDto {
|
||||||
|
List<Asset> getAssets() => assets.map(Asset.remote).toList();
|
||||||
}
|
}
|
||||||
|
BIN
mobile/lib/shared/models/album.g.dart
Normal file
BIN
mobile/lib/shared/models/album.g.dart
Normal file
Binary file not shown.
@ -1,60 +1,65 @@
|
|||||||
import 'package:hive/hive.dart';
|
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/exif_info.dart';
|
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
import 'package:immich_mobile/utils/builtin_extensions.dart';
|
import 'package:immich_mobile/utils/builtin_extensions.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
part 'asset.g.dart';
|
||||||
|
|
||||||
/// Asset (online or local)
|
/// Asset (online or local)
|
||||||
|
@Collection(inheritance: false)
|
||||||
class Asset {
|
class Asset {
|
||||||
Asset.remote(AssetResponseDto remote)
|
Asset.remote(AssetResponseDto remote)
|
||||||
: remoteId = remote.id,
|
: remoteId = remote.id,
|
||||||
fileCreatedAt = DateTime.parse(remote.fileCreatedAt),
|
isLocal = false,
|
||||||
fileModifiedAt = DateTime.parse(remote.fileModifiedAt),
|
fileCreatedAt = DateTime.parse(remote.fileCreatedAt).toUtc(),
|
||||||
|
fileModifiedAt = DateTime.parse(remote.fileModifiedAt).toUtc(),
|
||||||
|
updatedAt = DateTime.parse(remote.updatedAt).toUtc(),
|
||||||
durationInSeconds = remote.duration.toDuration().inSeconds,
|
durationInSeconds = remote.duration.toDuration().inSeconds,
|
||||||
fileName = p.basename(remote.originalPath),
|
fileName = p.basename(remote.originalPath),
|
||||||
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
||||||
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
||||||
livePhotoVideoId = remote.livePhotoVideoId,
|
livePhotoVideoId = remote.livePhotoVideoId,
|
||||||
deviceAssetId = remote.deviceAssetId,
|
localId = remote.deviceAssetId,
|
||||||
deviceId = remote.deviceId,
|
deviceId = fastHash(remote.deviceId),
|
||||||
ownerId = remote.ownerId,
|
ownerId = fastHash(remote.ownerId),
|
||||||
latitude = remote.exifInfo?.latitude?.toDouble(),
|
|
||||||
longitude = remote.exifInfo?.longitude?.toDouble(),
|
|
||||||
exifInfo =
|
exifInfo =
|
||||||
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
|
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
|
||||||
isFavorite = remote.isFavorite;
|
isFavorite = remote.isFavorite;
|
||||||
|
|
||||||
Asset.local(AssetEntity local, String owner)
|
Asset.local(AssetEntity local)
|
||||||
: localId = local.id,
|
: localId = local.id,
|
||||||
latitude = local.latitude,
|
isLocal = true,
|
||||||
longitude = local.longitude,
|
|
||||||
durationInSeconds = local.duration,
|
durationInSeconds = local.duration,
|
||||||
height = local.height,
|
height = local.height,
|
||||||
width = local.width,
|
width = local.width,
|
||||||
fileName = local.title!,
|
fileName = local.title!,
|
||||||
deviceAssetId = local.id,
|
deviceId = Store.get(StoreKey.deviceIdHash),
|
||||||
deviceId = Hive.box(userInfoBox).get(deviceIdKey),
|
ownerId = Store.get<User>(StoreKey.currentUser)!.isarId,
|
||||||
ownerId = owner,
|
|
||||||
fileModifiedAt = local.modifiedDateTime.toUtc(),
|
fileModifiedAt = local.modifiedDateTime.toUtc(),
|
||||||
|
updatedAt = local.modifiedDateTime.toUtc(),
|
||||||
isFavorite = local.isFavorite,
|
isFavorite = local.isFavorite,
|
||||||
fileCreatedAt = local.createDateTime.toUtc() {
|
fileCreatedAt = local.createDateTime.toUtc() {
|
||||||
if (fileCreatedAt.year == 1970) {
|
if (fileCreatedAt.year == 1970) {
|
||||||
fileCreatedAt = fileModifiedAt;
|
fileCreatedAt = fileModifiedAt;
|
||||||
}
|
}
|
||||||
|
if (local.latitude != null) {
|
||||||
|
exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Asset({
|
Asset({
|
||||||
this.localId,
|
|
||||||
this.remoteId,
|
this.remoteId,
|
||||||
required this.deviceAssetId,
|
required this.localId,
|
||||||
required this.deviceId,
|
required this.deviceId,
|
||||||
required this.ownerId,
|
required this.ownerId,
|
||||||
required this.fileCreatedAt,
|
required this.fileCreatedAt,
|
||||||
required this.fileModifiedAt,
|
required this.fileModifiedAt,
|
||||||
this.latitude,
|
required this.updatedAt,
|
||||||
this.longitude,
|
|
||||||
required this.durationInSeconds,
|
required this.durationInSeconds,
|
||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
@ -62,21 +67,22 @@ class Asset {
|
|||||||
this.livePhotoVideoId,
|
this.livePhotoVideoId,
|
||||||
this.exifInfo,
|
this.exifInfo,
|
||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
|
required this.isLocal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ignore
|
||||||
AssetEntity? _local;
|
AssetEntity? _local;
|
||||||
|
|
||||||
|
@ignore
|
||||||
AssetEntity? get local {
|
AssetEntity? get local {
|
||||||
if (isLocal && _local == null) {
|
if (isLocal && _local == null) {
|
||||||
_local = AssetEntity(
|
_local = AssetEntity(
|
||||||
id: localId!.toString(),
|
id: localId.toString(),
|
||||||
typeInt: isImage ? 1 : 2,
|
typeInt: isImage ? 1 : 2,
|
||||||
width: width!,
|
width: width!,
|
||||||
height: height!,
|
height: height!,
|
||||||
duration: durationInSeconds,
|
duration: durationInSeconds,
|
||||||
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
|
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
|
||||||
latitude: latitude,
|
|
||||||
longitude: longitude,
|
|
||||||
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
|
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
|
||||||
title: fileName,
|
title: fileName,
|
||||||
);
|
);
|
||||||
@ -84,110 +90,136 @@ class Asset {
|
|||||||
return _local;
|
return _local;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? localId;
|
Id id = Isar.autoIncrement;
|
||||||
|
|
||||||
|
@Index(unique: false, replace: false, type: IndexType.hash)
|
||||||
String? remoteId;
|
String? remoteId;
|
||||||
|
|
||||||
String deviceAssetId;
|
@Index(
|
||||||
|
unique: true,
|
||||||
|
replace: false,
|
||||||
|
type: IndexType.hash,
|
||||||
|
composite: [CompositeIndex('deviceId')],
|
||||||
|
)
|
||||||
|
String localId;
|
||||||
|
|
||||||
String deviceId;
|
int deviceId;
|
||||||
|
|
||||||
String ownerId;
|
int ownerId;
|
||||||
|
|
||||||
DateTime fileCreatedAt;
|
DateTime fileCreatedAt;
|
||||||
|
|
||||||
DateTime fileModifiedAt;
|
DateTime fileModifiedAt;
|
||||||
|
|
||||||
double? latitude;
|
DateTime updatedAt;
|
||||||
|
|
||||||
double? longitude;
|
|
||||||
|
|
||||||
int durationInSeconds;
|
int durationInSeconds;
|
||||||
|
|
||||||
int? width;
|
short? width;
|
||||||
|
|
||||||
int? height;
|
short? height;
|
||||||
|
|
||||||
String fileName;
|
String fileName;
|
||||||
|
|
||||||
String? livePhotoVideoId;
|
String? livePhotoVideoId;
|
||||||
|
|
||||||
ExifInfo? exifInfo;
|
|
||||||
|
|
||||||
bool isFavorite;
|
bool isFavorite;
|
||||||
|
|
||||||
String get id => isLocal ? localId.toString() : remoteId!;
|
bool isLocal;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
ExifInfo? exifInfo;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool get isInDb => id != Isar.autoIncrement;
|
||||||
|
|
||||||
|
@ignore
|
||||||
String get name => p.withoutExtension(fileName);
|
String get name => p.withoutExtension(fileName);
|
||||||
|
|
||||||
|
@ignore
|
||||||
bool get isRemote => remoteId != null;
|
bool get isRemote => remoteId != null;
|
||||||
|
|
||||||
bool get isLocal => localId != null;
|
@ignore
|
||||||
|
|
||||||
bool get isImage => durationInSeconds == 0;
|
bool get isImage => durationInSeconds == 0;
|
||||||
|
|
||||||
|
@ignore
|
||||||
Duration get duration => Duration(seconds: durationInSeconds);
|
Duration get duration => Duration(seconds: durationInSeconds);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(other) {
|
bool operator ==(other) {
|
||||||
if (other is! Asset) return false;
|
if (other is! Asset) return false;
|
||||||
return id == other.id && isLocal == other.isLocal;
|
return id == other.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ignore
|
||||||
int get hashCode => id.hashCode;
|
int get hashCode => id.hashCode;
|
||||||
|
|
||||||
// methods below are only required for caching as JSON
|
bool updateFromAssetEntity(AssetEntity ae) {
|
||||||
|
// TODO check more fields;
|
||||||
Map<String, dynamic> toJson() {
|
// width and height are most important because local assets require these
|
||||||
final json = <String, dynamic>{};
|
final bool hasChanges =
|
||||||
json["localId"] = localId;
|
isLocal == false || width != ae.width || height != ae.height;
|
||||||
json["remoteId"] = remoteId;
|
if (hasChanges) {
|
||||||
json["deviceAssetId"] = deviceAssetId;
|
isLocal = true;
|
||||||
json["deviceId"] = deviceId;
|
width = ae.width;
|
||||||
json["ownerId"] = ownerId;
|
height = ae.height;
|
||||||
json["fileCreatedAt"] = fileCreatedAt.millisecondsSinceEpoch;
|
|
||||||
json["fileModifiedAt"] = fileModifiedAt.millisecondsSinceEpoch;
|
|
||||||
json["latitude"] = latitude;
|
|
||||||
json["longitude"] = longitude;
|
|
||||||
json["durationInSeconds"] = durationInSeconds;
|
|
||||||
json["width"] = width;
|
|
||||||
json["height"] = height;
|
|
||||||
json["fileName"] = fileName;
|
|
||||||
json["livePhotoVideoId"] = livePhotoVideoId;
|
|
||||||
json["isFavorite"] = isFavorite;
|
|
||||||
if (exifInfo != null) {
|
|
||||||
json["exifInfo"] = exifInfo!.toJson();
|
|
||||||
}
|
}
|
||||||
return json;
|
return hasChanges;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Asset? fromJson(dynamic value) {
|
Asset withUpdatesFromDto(AssetResponseDto dto) =>
|
||||||
if (value is Map) {
|
Asset.remote(dto).updateFromDb(this);
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
return Asset(
|
Asset updateFromDb(Asset a) {
|
||||||
localId: json["localId"],
|
assert(localId == a.localId);
|
||||||
remoteId: json["remoteId"],
|
assert(deviceId == a.deviceId);
|
||||||
deviceAssetId: json["deviceAssetId"],
|
id = a.id;
|
||||||
deviceId: json["deviceId"],
|
isLocal |= a.isLocal;
|
||||||
ownerId: json["ownerId"],
|
remoteId ??= a.remoteId;
|
||||||
fileCreatedAt:
|
width ??= a.width;
|
||||||
DateTime.fromMillisecondsSinceEpoch(json["fileCreatedAt"], isUtc: true),
|
height ??= a.height;
|
||||||
fileModifiedAt: DateTime.fromMillisecondsSinceEpoch(
|
exifInfo ??= a.exifInfo;
|
||||||
json["fileModifiedAt"],
|
exifInfo?.id = id;
|
||||||
isUtc: true,
|
return this;
|
||||||
),
|
}
|
||||||
latitude: json["latitude"],
|
|
||||||
longitude: json["longitude"],
|
Future<void> put(Isar db) async {
|
||||||
durationInSeconds: json["durationInSeconds"],
|
await db.assets.put(this);
|
||||||
width: json["width"],
|
if (exifInfo != null) {
|
||||||
height: json["height"],
|
exifInfo!.id = id;
|
||||||
fileName: json["fileName"],
|
await db.exifInfos.put(exifInfo!);
|
||||||
livePhotoVideoId: json["livePhotoVideoId"],
|
|
||||||
exifInfo: ExifInfo.fromJson(json["exifInfo"]),
|
|
||||||
isFavorite: json["isFavorite"],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
static int compareByDeviceIdLocalId(Asset a, Asset b) {
|
||||||
|
final int order = a.deviceId.compareTo(b.deviceId);
|
||||||
|
return order == 0 ? a.localId.compareTo(b.localId) : order;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
|
||||||
|
|
||||||
|
static int compareByLocalId(Asset a, Asset b) =>
|
||||||
|
a.localId.compareTo(b.localId);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AssetsHelper on IsarCollection<Asset> {
|
||||||
|
Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
|
||||||
|
ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();
|
||||||
|
Future<int> deleteAllByLocalId(Iterable<String> ids) =>
|
||||||
|
ids.isEmpty ? Future.value(0) : _local(ids).deleteAll();
|
||||||
|
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) =>
|
||||||
|
ids.isEmpty ? Future.value([]) : _remote(ids).findAll();
|
||||||
|
Future<List<Asset>> getAllByLocalId(Iterable<String> ids) =>
|
||||||
|
ids.isEmpty ? Future.value([]) : _local(ids).findAll();
|
||||||
|
|
||||||
|
QueryBuilder<Asset, Asset, QAfterWhereClause> _remote(Iterable<String> ids) =>
|
||||||
|
where().anyOf(ids, (q, String e) => q.remoteIdEqualTo(e));
|
||||||
|
QueryBuilder<Asset, Asset, QAfterWhereClause> _local(Iterable<String> ids) {
|
||||||
|
return where().anyOf(
|
||||||
|
ids,
|
||||||
|
(q, String e) =>
|
||||||
|
q.localIdDeviceIdEqualTo(e, Store.get(StoreKey.deviceIdHash)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
mobile/lib/shared/models/asset.g.dart
Normal file
BIN
mobile/lib/shared/models/asset.g.dart
Normal file
Binary file not shown.
@ -1,86 +1,93 @@
|
|||||||
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:immich_mobile/utils/builtin_extensions.dart';
|
import 'package:immich_mobile/utils/builtin_extensions.dart';
|
||||||
|
|
||||||
|
part 'exif_info.g.dart';
|
||||||
|
|
||||||
|
/// Exif information 1:1 relation with Asset
|
||||||
|
@Collection(inheritance: false)
|
||||||
class ExifInfo {
|
class ExifInfo {
|
||||||
|
Id? id;
|
||||||
int? fileSize;
|
int? fileSize;
|
||||||
String? make;
|
String? make;
|
||||||
String? model;
|
String? model;
|
||||||
String? orientation;
|
String? lens;
|
||||||
String? lensModel;
|
float? f;
|
||||||
double? fNumber;
|
float? mm;
|
||||||
double? focalLength;
|
short? iso;
|
||||||
int? iso;
|
float? exposureSeconds;
|
||||||
double? exposureTime;
|
float? lat;
|
||||||
|
float? long;
|
||||||
String? city;
|
String? city;
|
||||||
String? state;
|
String? state;
|
||||||
String? country;
|
String? country;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
String get exposureTime {
|
||||||
|
if (exposureSeconds == null) {
|
||||||
|
return "";
|
||||||
|
} else if (exposureSeconds! < 1) {
|
||||||
|
return "1/${(1.0 / exposureSeconds!).round()} s";
|
||||||
|
} else {
|
||||||
|
return "${exposureSeconds!.toStringAsFixed(1)} s";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
String get fNumber => f != null ? f!.toStringAsFixed(1) : "";
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
double? get latitude => lat;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
double? get longitude => long;
|
||||||
|
|
||||||
ExifInfo.fromDto(ExifResponseDto dto)
|
ExifInfo.fromDto(ExifResponseDto dto)
|
||||||
: fileSize = dto.fileSizeInByte,
|
: fileSize = dto.fileSizeInByte,
|
||||||
make = dto.make,
|
make = dto.make,
|
||||||
model = dto.model,
|
model = dto.model,
|
||||||
orientation = dto.orientation,
|
lens = dto.lensModel,
|
||||||
lensModel = dto.lensModel,
|
f = dto.fNumber?.toDouble(),
|
||||||
fNumber = dto.fNumber?.toDouble(),
|
mm = dto.focalLength?.toDouble(),
|
||||||
focalLength = dto.focalLength?.toDouble(),
|
|
||||||
iso = dto.iso?.toInt(),
|
iso = dto.iso?.toInt(),
|
||||||
exposureTime = dto.exposureTime?.toDouble(),
|
exposureSeconds = _exposureTimeToSeconds(dto.exposureTime),
|
||||||
|
lat = dto.latitude?.toDouble(),
|
||||||
|
long = dto.longitude?.toDouble(),
|
||||||
city = dto.city,
|
city = dto.city,
|
||||||
state = dto.state,
|
state = dto.state,
|
||||||
country = dto.country;
|
country = dto.country;
|
||||||
|
|
||||||
// stuff below is only required for caching as JSON
|
ExifInfo({
|
||||||
|
|
||||||
ExifInfo(
|
|
||||||
this.fileSize,
|
this.fileSize,
|
||||||
this.make,
|
this.make,
|
||||||
this.model,
|
this.model,
|
||||||
this.orientation,
|
this.lens,
|
||||||
this.lensModel,
|
this.f,
|
||||||
this.fNumber,
|
this.mm,
|
||||||
this.focalLength,
|
|
||||||
this.iso,
|
this.iso,
|
||||||
this.exposureTime,
|
this.exposureSeconds,
|
||||||
|
this.lat,
|
||||||
|
this.long,
|
||||||
this.city,
|
this.city,
|
||||||
this.state,
|
this.state,
|
||||||
this.country,
|
this.country,
|
||||||
);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
double? _exposureTimeToSeconds(String? s) {
|
||||||
final json = <String, dynamic>{};
|
if (s == null) {
|
||||||
json["fileSize"] = fileSize;
|
|
||||||
json["make"] = make;
|
|
||||||
json["model"] = model;
|
|
||||||
json["orientation"] = orientation;
|
|
||||||
json["lensModel"] = lensModel;
|
|
||||||
json["fNumber"] = fNumber;
|
|
||||||
json["focalLength"] = focalLength;
|
|
||||||
json["iso"] = iso;
|
|
||||||
json["exposureTime"] = exposureTime;
|
|
||||||
json["city"] = city;
|
|
||||||
json["state"] = state;
|
|
||||||
json["country"] = country;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
static ExifInfo? fromJson(dynamic value) {
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
return ExifInfo(
|
|
||||||
json["fileSize"],
|
|
||||||
json["make"],
|
|
||||||
json["model"],
|
|
||||||
json["orientation"],
|
|
||||||
json["lensModel"],
|
|
||||||
json["fNumber"],
|
|
||||||
json["focalLength"],
|
|
||||||
json["iso"],
|
|
||||||
json["exposureTime"],
|
|
||||||
json["city"],
|
|
||||||
json["state"],
|
|
||||||
json["country"],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
double? value = double.tryParse(s);
|
||||||
|
if (value != null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
final parts = s.split("/");
|
||||||
|
if (parts.length == 2) {
|
||||||
|
return parts[0].toDouble() / parts[1].toDouble();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
BIN
mobile/lib/shared/models/exif_info.g.dart
Normal file
BIN
mobile/lib/shared/models/exif_info.g.dart
Normal file
Binary file not shown.
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
@ -25,26 +26,28 @@ class Store {
|
|||||||
|
|
||||||
/// Returns the stored value for the given key, or the default value if null
|
/// Returns the stored value for the given key, or the default value if null
|
||||||
static T? get<T>(StoreKey key, [T? defaultValue]) =>
|
static T? get<T>(StoreKey key, [T? defaultValue]) =>
|
||||||
_cache[key._id] ?? defaultValue;
|
_cache[key.id] ?? defaultValue;
|
||||||
|
|
||||||
/// Stores the value synchronously in the cache and asynchronously in the DB
|
/// Stores the value synchronously in the cache and asynchronously in the DB
|
||||||
static Future<void> put<T>(StoreKey key, T value) {
|
static Future<void> put<T>(StoreKey key, T value) {
|
||||||
_cache[key._id] = value;
|
_cache[key.id] = value;
|
||||||
return _db.writeTxn(() => _db.storeValues.put(StoreValue._of(value, key)));
|
return _db.writeTxn(
|
||||||
|
() async => _db.storeValues.put(await StoreValue._of(value, key)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes the value synchronously from the cache and asynchronously from the DB
|
/// Removes the value synchronously from the cache and asynchronously from the DB
|
||||||
static Future<void> delete(StoreKey key) {
|
static Future<void> delete(StoreKey key) {
|
||||||
_cache[key._id] = null;
|
_cache[key.id] = null;
|
||||||
return _db.writeTxn(() => _db.storeValues.delete(key._id));
|
return _db.writeTxn(() => _db.storeValues.delete(key.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fills the cache with the values from the DB
|
/// Fills the cache with the values from the DB
|
||||||
static _populateCache() {
|
static _populateCache() {
|
||||||
for (StoreKey key in StoreKey.values) {
|
for (StoreKey key in StoreKey.values) {
|
||||||
final StoreValue? value = _db.storeValues.getSync(key._id);
|
final StoreValue? value = _db.storeValues.getSync(key.id);
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
_cache[key._id] = value._extract(key);
|
_cache[key.id] = value._extract(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,17 +70,22 @@ class StoreValue {
|
|||||||
int? intValue;
|
int? intValue;
|
||||||
String? strValue;
|
String? strValue;
|
||||||
|
|
||||||
T? _extract<T>(StoreKey key) => key._isInt
|
T? _extract<T>(StoreKey key) => key.isInt
|
||||||
? intValue
|
? (key.fromDb == null ? intValue : key.fromDb!.call(Store._db, intValue!))
|
||||||
: (key._fromJson != null
|
: (key.fromJson != null
|
||||||
? key._fromJson!(json.decode(strValue!))
|
? key.fromJson!(json.decode(strValue!))
|
||||||
: strValue);
|
: strValue);
|
||||||
static StoreValue _of(dynamic value, StoreKey key) => StoreValue(
|
static Future<StoreValue> _of(dynamic value, StoreKey key) async =>
|
||||||
key._id,
|
StoreValue(
|
||||||
intValue: key._isInt ? value : null,
|
key.id,
|
||||||
strValue: key._isInt
|
intValue: key.isInt
|
||||||
|
? (key.toDb == null
|
||||||
|
? value
|
||||||
|
: await key.toDb!.call(Store._db, value))
|
||||||
|
: null,
|
||||||
|
strValue: key.isInt
|
||||||
? null
|
? null
|
||||||
: (key._fromJson == null ? value : json.encode(value.toJson())),
|
: (key.fromJson == null ? value : json.encode(value.toJson())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,11 +94,28 @@ class StoreValue {
|
|||||||
enum StoreKey {
|
enum StoreKey {
|
||||||
userRemoteId(0),
|
userRemoteId(0),
|
||||||
assetETag(1),
|
assetETag(1),
|
||||||
|
currentUser(2, isInt: true, fromDb: _getUser, toDb: _toUser),
|
||||||
|
deviceIdHash(3, isInt: true),
|
||||||
|
deviceId(4),
|
||||||
;
|
;
|
||||||
|
|
||||||
// ignore: unused_element
|
const StoreKey(
|
||||||
const StoreKey(this._id, [this._isInt = false, this._fromJson]);
|
this.id, {
|
||||||
final int _id;
|
this.isInt = false,
|
||||||
final bool _isInt;
|
this.fromDb,
|
||||||
final Function(dynamic)? _fromJson;
|
this.toDb,
|
||||||
|
// ignore: unused_element
|
||||||
|
this.fromJson,
|
||||||
|
});
|
||||||
|
final int id;
|
||||||
|
final bool isInt;
|
||||||
|
final dynamic Function(Isar, int)? fromDb;
|
||||||
|
final Future<int> Function(Isar, dynamic)? toDb;
|
||||||
|
final Function(dynamic)? fromJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
User? _getUser(Isar db, int i) => db.users.getSync(i);
|
||||||
|
Future<int> _toUser(Isar db, dynamic u) {
|
||||||
|
User user = (u as User);
|
||||||
|
return db.users.put(user);
|
||||||
}
|
}
|
||||||
|
@ -1,94 +1,63 @@
|
|||||||
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
part 'user.g.dart';
|
||||||
|
|
||||||
|
@Collection(inheritance: false)
|
||||||
class User {
|
class User {
|
||||||
User({
|
User({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
required this.updatedAt,
|
||||||
required this.email,
|
required this.email,
|
||||||
required this.firstName,
|
required this.firstName,
|
||||||
required this.lastName,
|
required this.lastName,
|
||||||
required this.profileImagePath,
|
|
||||||
required this.isAdmin,
|
required this.isAdmin,
|
||||||
required this.oauthId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Id get isarId => fastHash(id);
|
||||||
|
|
||||||
User.fromDto(UserResponseDto dto)
|
User.fromDto(UserResponseDto dto)
|
||||||
: id = dto.id,
|
: id = dto.id,
|
||||||
|
updatedAt = dto.updatedAt != null
|
||||||
|
? DateTime.parse(dto.updatedAt!).toUtc()
|
||||||
|
: DateTime.now().toUtc(),
|
||||||
email = dto.email,
|
email = dto.email,
|
||||||
firstName = dto.firstName,
|
firstName = dto.firstName,
|
||||||
lastName = dto.lastName,
|
lastName = dto.lastName,
|
||||||
profileImagePath = dto.profileImagePath,
|
isAdmin = dto.isAdmin;
|
||||||
isAdmin = dto.isAdmin,
|
|
||||||
oauthId = dto.oauthId;
|
|
||||||
|
|
||||||
|
@Index(unique: true, replace: false, type: IndexType.hash)
|
||||||
String id;
|
String id;
|
||||||
|
DateTime updatedAt;
|
||||||
String email;
|
String email;
|
||||||
String firstName;
|
String firstName;
|
||||||
String lastName;
|
String lastName;
|
||||||
String profileImagePath;
|
|
||||||
bool isAdmin;
|
bool isAdmin;
|
||||||
String oauthId;
|
@Backlink(to: 'owner')
|
||||||
|
final IsarLinks<Album> albums = IsarLinks<Album>();
|
||||||
|
@Backlink(to: 'sharedUsers')
|
||||||
|
final IsarLinks<Album> sharedAlbums = IsarLinks<Album>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(other) {
|
bool operator ==(other) {
|
||||||
if (other is! User) return false;
|
if (other is! User) return false;
|
||||||
return id == other.id &&
|
return id == other.id &&
|
||||||
|
updatedAt == other.updatedAt &&
|
||||||
email == other.email &&
|
email == other.email &&
|
||||||
firstName == other.firstName &&
|
firstName == other.firstName &&
|
||||||
lastName == other.lastName &&
|
lastName == other.lastName &&
|
||||||
profileImagePath == other.profileImagePath &&
|
isAdmin == other.isAdmin;
|
||||||
isAdmin == other.isAdmin &&
|
|
||||||
oauthId == other.oauthId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ignore
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
id.hashCode ^
|
id.hashCode ^
|
||||||
|
updatedAt.hashCode ^
|
||||||
email.hashCode ^
|
email.hashCode ^
|
||||||
firstName.hashCode ^
|
firstName.hashCode ^
|
||||||
lastName.hashCode ^
|
lastName.hashCode ^
|
||||||
profileImagePath.hashCode ^
|
isAdmin.hashCode;
|
||||||
isAdmin.hashCode ^
|
|
||||||
oauthId.hashCode;
|
|
||||||
|
|
||||||
UserResponseDto toDto() {
|
|
||||||
return UserResponseDto(
|
|
||||||
id: id,
|
|
||||||
email: email,
|
|
||||||
firstName: firstName,
|
|
||||||
lastName: lastName,
|
|
||||||
profileImagePath: profileImagePath,
|
|
||||||
createdAt: '',
|
|
||||||
isAdmin: isAdmin,
|
|
||||||
shouldChangePassword: false,
|
|
||||||
oauthId: oauthId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
final json = <String, dynamic>{};
|
|
||||||
json["id"] = id;
|
|
||||||
json["email"] = email;
|
|
||||||
json["firstName"] = firstName;
|
|
||||||
json["lastName"] = lastName;
|
|
||||||
json["profileImagePath"] = profileImagePath;
|
|
||||||
json["isAdmin"] = isAdmin;
|
|
||||||
json["oauthId"] = oauthId;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
static User? fromJson(dynamic value) {
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
return User(
|
|
||||||
id: json["id"],
|
|
||||||
email: json["email"],
|
|
||||||
firstName: json["firstName"],
|
|
||||||
lastName: json["lastName"],
|
|
||||||
profileImagePath: json["profileImagePath"],
|
|
||||||
isAdmin: json["isAdmin"],
|
|
||||||
oauthId: json["oauthId"],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
BIN
mobile/lib/shared/models/user.g.dart
Normal file
BIN
mobile/lib/shared/models/user.g.dart
Normal file
Binary file not shown.
@ -1,20 +1,19 @@
|
|||||||
import 'dart:collection';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hive/hive.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/asset.service.dart';
|
import 'package:immich_mobile/shared/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
|
|
||||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:immich_mobile/utils/tuple.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
@ -50,50 +49,36 @@ class AssetsState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CombineAssetsComputeParameters {
|
|
||||||
final Iterable<Asset> local;
|
|
||||||
final Iterable<Asset> remote;
|
|
||||||
final String deviceId;
|
|
||||||
|
|
||||||
_CombineAssetsComputeParameters(this.local, this.remote, this.deviceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
class AssetNotifier extends StateNotifier<AssetsState> {
|
class AssetNotifier extends StateNotifier<AssetsState> {
|
||||||
final AssetService _assetService;
|
final AssetService _assetService;
|
||||||
final AssetCacheService _assetCacheService;
|
|
||||||
final AppSettingsService _settingsService;
|
final AppSettingsService _settingsService;
|
||||||
|
final AlbumService _albumService;
|
||||||
|
final Isar _db;
|
||||||
final log = Logger('AssetNotifier');
|
final log = Logger('AssetNotifier');
|
||||||
final DeviceInfoService _deviceInfoService = DeviceInfoService();
|
|
||||||
bool _getAllAssetInProgress = false;
|
bool _getAllAssetInProgress = false;
|
||||||
bool _deleteInProgress = false;
|
bool _deleteInProgress = false;
|
||||||
|
|
||||||
AssetNotifier(
|
AssetNotifier(
|
||||||
this._assetService,
|
this._assetService,
|
||||||
this._assetCacheService,
|
|
||||||
this._settingsService,
|
this._settingsService,
|
||||||
|
this._albumService,
|
||||||
|
this._db,
|
||||||
) : super(AssetsState.fromAssetList([]));
|
) : super(AssetsState.fromAssetList([]));
|
||||||
|
|
||||||
Future<void> _updateAssetsState(
|
Future<void> _updateAssetsState(List<Asset> newAssetList) async {
|
||||||
List<Asset> newAssetList, {
|
|
||||||
bool cache = true,
|
|
||||||
}) async {
|
|
||||||
if (cache) {
|
|
||||||
_assetCacheService.put(newAssetList);
|
|
||||||
}
|
|
||||||
|
|
||||||
final layout = AssetGridLayoutParameters(
|
final layout = AssetGridLayoutParameters(
|
||||||
_settingsService.getSetting(AppSettingsEnum.tilesPerRow),
|
_settingsService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||||
_settingsService.getSetting(AppSettingsEnum.dynamicLayout),
|
_settingsService.getSetting(AppSettingsEnum.dynamicLayout),
|
||||||
GroupAssetsBy.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
|
GroupAssetsBy
|
||||||
|
.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
|
||||||
);
|
);
|
||||||
|
|
||||||
state = await AssetsState.fromAssetList(newAssetList)
|
state = await AssetsState.fromAssetList(newAssetList)
|
||||||
.withRenderDataStructure(layout);
|
.withRenderDataStructure(layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Just a little helper to trigger a rebuild of the state object
|
// Just a little helper to trigger a rebuild of the state object
|
||||||
Future<void> rebuildAssetGridDataStructure() async {
|
Future<void> rebuildAssetGridDataStructure() async {
|
||||||
await _updateAssetsState(state.allAssets, cache: false);
|
await _updateAssetsState(state.allAssets);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllAsset() async {
|
getAllAsset() async {
|
||||||
@ -104,127 +89,102 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
|||||||
final stopwatch = Stopwatch();
|
final stopwatch = Stopwatch();
|
||||||
try {
|
try {
|
||||||
_getAllAssetInProgress = true;
|
_getAllAssetInProgress = true;
|
||||||
bool isCacheValid = await _assetCacheService.isValid();
|
final User me = Store.get(StoreKey.currentUser);
|
||||||
|
final int cachedCount =
|
||||||
|
await _db.assets.filter().ownerIdEqualTo(me.isarId).count();
|
||||||
stopwatch.start();
|
stopwatch.start();
|
||||||
if (isCacheValid && state.allAssets.isEmpty) {
|
if (cachedCount > 0 && cachedCount != state.allAssets.length) {
|
||||||
final List<Asset>? cachedData = await _assetCacheService.get();
|
await _updateAssetsState(await _getUserAssets(me.isarId));
|
||||||
if (cachedData == null) {
|
log.info(
|
||||||
isCacheValid = false;
|
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
|
||||||
log.warning("Cached asset data is invalid, fetching new data");
|
);
|
||||||
} else {
|
|
||||||
await _updateAssetsState(cachedData, cache: false);
|
|
||||||
log.info(
|
|
||||||
"Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
stopwatch.reset();
|
stopwatch.reset();
|
||||||
}
|
}
|
||||||
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
|
final bool newRemote = await _assetService.refreshRemoteAssets();
|
||||||
final remoteTask = _assetService.getRemoteAssets(
|
final bool newLocal = await _albumService.refreshDeviceAlbums();
|
||||||
etag: isCacheValid ? Store.get(StoreKey.assetETag) : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote);
|
|
||||||
remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin;
|
|
||||||
|
|
||||||
final List<Asset> currentLocal = state.allAssets.slice(0, remoteBegin);
|
|
||||||
|
|
||||||
final Pair<List<Asset>?, String?> remoteResult = await remoteTask;
|
|
||||||
List<Asset>? newRemote = remoteResult.first;
|
|
||||||
List<Asset>? newLocal = await localTask;
|
|
||||||
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
|
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
|
||||||
stopwatch.reset();
|
stopwatch.reset();
|
||||||
if (newRemote == null &&
|
if (!newRemote && !newLocal) {
|
||||||
(newLocal == null || currentLocal.equals(newLocal))) {
|
|
||||||
log.info("state is already up-to-date");
|
log.info("state is already up-to-date");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
newRemote ??= state.allAssets.slice(remoteBegin);
|
stopwatch.reset();
|
||||||
newLocal ??= [];
|
final assets = await _getUserAssets(me.isarId);
|
||||||
|
if (!const ListEquality().equals(assets, state.allAssets)) {
|
||||||
final combinedAssets = await _combineLocalAndRemoteAssets(
|
log.info("setting new asset state");
|
||||||
local: newLocal,
|
await _updateAssetsState(assets);
|
||||||
remote: newRemote,
|
}
|
||||||
);
|
|
||||||
await _updateAssetsState(combinedAssets);
|
|
||||||
|
|
||||||
log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
|
|
||||||
|
|
||||||
Store.put(StoreKey.assetETag, remoteResult.second);
|
|
||||||
} finally {
|
} finally {
|
||||||
_getAllAssetInProgress = false;
|
_getAllAssetInProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<Asset>> _computeCombine(
|
Future<List<Asset>> _getUserAssets(int userId) => _db.assets
|
||||||
_CombineAssetsComputeParameters data,
|
.filter()
|
||||||
) async {
|
.ownerIdEqualTo(userId)
|
||||||
var local = data.local;
|
.sortByFileCreatedAtDesc()
|
||||||
var remote = data.remote;
|
.findAll();
|
||||||
final deviceId = data.deviceId;
|
|
||||||
|
|
||||||
final List<Asset> assets = [];
|
Future<void> clearAllAsset() {
|
||||||
if (remote.isNotEmpty && local.isNotEmpty) {
|
state = AssetsState.empty();
|
||||||
final Set<String> existingIds = remote
|
return _db.writeTxn(() async {
|
||||||
.where((e) => e.deviceId == deviceId)
|
await _db.assets.clear();
|
||||||
.map((e) => e.deviceAssetId)
|
await _db.exifInfos.clear();
|
||||||
.toSet();
|
await _db.albums.clear();
|
||||||
local = local.where((e) => !existingIds.contains(e.id));
|
});
|
||||||
}
|
|
||||||
assets.addAll(local);
|
|
||||||
// the order (first all local, then remote assets) is important!
|
|
||||||
assets.addAll(remote);
|
|
||||||
return assets;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Asset>> _combineLocalAndRemoteAssets({
|
Future<void> onNewAssetUploaded(Asset newAsset) async {
|
||||||
required Iterable<Asset> local,
|
|
||||||
required List<Asset> remote,
|
|
||||||
}) async {
|
|
||||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
|
||||||
return await compute(
|
|
||||||
_computeCombine,
|
|
||||||
_CombineAssetsComputeParameters(local, remote, deviceId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAllAsset() {
|
|
||||||
_updateAssetsState([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
void onNewAssetUploaded(Asset newAsset) {
|
|
||||||
final int i = state.allAssets.indexWhere(
|
final int i = state.allAssets.indexWhere(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.isRemote ||
|
a.isRemote ||
|
||||||
(a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId),
|
(a.localId == newAsset.localId && a.deviceId == newAsset.deviceId),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) {
|
if (i == -1 ||
|
||||||
_updateAssetsState([...state.allAssets, newAsset]);
|
state.allAssets[i].localId != newAsset.localId ||
|
||||||
|
state.allAssets[i].deviceId != newAsset.deviceId) {
|
||||||
|
await _updateAssetsState([...state.allAssets, newAsset]);
|
||||||
} else {
|
} else {
|
||||||
|
// unify local/remote assets by replacing the
|
||||||
|
// local-only asset in the DB with a local&remote asset
|
||||||
|
final Asset? inDb = await _db.assets
|
||||||
|
.where()
|
||||||
|
.localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId)
|
||||||
|
.findFirst();
|
||||||
|
if (inDb != null) {
|
||||||
|
newAsset.id = inDb.id;
|
||||||
|
newAsset.isLocal = inDb.isLocal;
|
||||||
|
}
|
||||||
|
|
||||||
// order is important to keep all local-only assets at the beginning!
|
// order is important to keep all local-only assets at the beginning!
|
||||||
_updateAssetsState([
|
await _updateAssetsState([
|
||||||
...state.allAssets.slice(0, i),
|
...state.allAssets.slice(0, i),
|
||||||
...state.allAssets.slice(i + 1),
|
...state.allAssets.slice(i + 1),
|
||||||
newAsset,
|
newAsset,
|
||||||
]);
|
]);
|
||||||
// TODO here is a place to unify local/remote assets by replacing the
|
}
|
||||||
// local-only asset in the state with a local&remote asset
|
try {
|
||||||
|
await _db.writeTxn(() => newAsset.put(_db));
|
||||||
|
} on IsarError catch (e) {
|
||||||
|
debugPrint(e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteAssets(Set<Asset> deleteAssets) async {
|
Future<void> deleteAssets(Set<Asset> deleteAssets) async {
|
||||||
_deleteInProgress = true;
|
_deleteInProgress = true;
|
||||||
try {
|
try {
|
||||||
|
_updateAssetsState(
|
||||||
|
state.allAssets.whereNot(deleteAssets.contains).toList(),
|
||||||
|
);
|
||||||
final localDeleted = await _deleteLocalAssets(deleteAssets);
|
final localDeleted = await _deleteLocalAssets(deleteAssets);
|
||||||
final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
|
final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
|
||||||
final Set<String> deleted = HashSet();
|
if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
|
||||||
deleted.addAll(localDeleted);
|
final dbIds = deleteAssets.map((e) => e.id).toList();
|
||||||
deleted.addAll(remoteDeleted);
|
await _db.writeTxn(() async {
|
||||||
if (deleted.isNotEmpty) {
|
await _db.exifInfos.deleteAll(dbIds);
|
||||||
_updateAssetsState(
|
await _db.assets.deleteAll(dbIds);
|
||||||
state.allAssets.where((a) => !deleted.contains(a.id)).toList(),
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_deleteInProgress = false;
|
_deleteInProgress = false;
|
||||||
@ -232,16 +192,15 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
|
Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
|
||||||
var deviceInfo = await _deviceInfoService.getDeviceInfo();
|
final int deviceId = Store.get(StoreKey.deviceIdHash);
|
||||||
var deviceId = deviceInfo["deviceId"];
|
|
||||||
final List<String> local = [];
|
final List<String> local = [];
|
||||||
// Delete asset from device
|
// Delete asset from device
|
||||||
for (final Asset asset in assetsToDelete) {
|
for (final Asset asset in assetsToDelete) {
|
||||||
if (asset.isLocal) {
|
if (asset.isLocal) {
|
||||||
local.add(asset.localId!);
|
local.add(asset.localId);
|
||||||
} else if (asset.deviceId == deviceId) {
|
} else if (asset.deviceId == deviceId) {
|
||||||
// Delete asset on device if it is still present
|
// Delete asset on device if it is still present
|
||||||
var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
|
var localAsset = await AssetEntity.fromId(asset.localId);
|
||||||
if (localAsset != null) {
|
if (localAsset != null) {
|
||||||
local.add(localAsset.id);
|
local.add(localAsset.id);
|
||||||
}
|
}
|
||||||
@ -249,7 +208,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
|||||||
}
|
}
|
||||||
if (local.isNotEmpty) {
|
if (local.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
return await PhotoManager.editor.deleteWithIds(local);
|
await PhotoManager.editor.deleteWithIds(local);
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
log.severe("Failed to delete asset from device", e, stack);
|
log.severe("Failed to delete asset from device", e, stack);
|
||||||
}
|
}
|
||||||
@ -289,8 +248,9 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
|||||||
final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
|
final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
|
||||||
return AssetNotifier(
|
return AssetNotifier(
|
||||||
ref.watch(assetServiceProvider),
|
ref.watch(assetServiceProvider),
|
||||||
ref.watch(assetCacheServiceProvider),
|
|
||||||
ref.watch(appSettingsServiceProvider),
|
ref.watch(appSettingsServiceProvider),
|
||||||
|
ref.watch(albumServiceProvider),
|
||||||
|
ref.watch(dbProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -28,9 +28,13 @@ class ApiService {
|
|||||||
debugPrint("Cannot init ApiServer endpoint, userInfoBox not open yet.");
|
debugPrint("Cannot init ApiServer endpoint, userInfoBox not open yet.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
String? _authToken;
|
||||||
|
|
||||||
setEndpoint(String endpoint) {
|
setEndpoint(String endpoint) {
|
||||||
_apiClient = ApiClient(basePath: endpoint);
|
_apiClient = ApiClient(basePath: endpoint);
|
||||||
|
if (_authToken != null) {
|
||||||
|
setAccessToken(_authToken!);
|
||||||
|
}
|
||||||
userApi = UserApi(_apiClient);
|
userApi = UserApi(_apiClient);
|
||||||
authenticationApi = AuthenticationApi(_apiClient);
|
authenticationApi = AuthenticationApi(_apiClient);
|
||||||
oAuthApi = OAuthApi(_apiClient);
|
oAuthApi = OAuthApi(_apiClient);
|
||||||
@ -94,6 +98,9 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAccessToken(String accessToken) {
|
setAccessToken(String accessToken) {
|
||||||
|
_authToken = accessToken;
|
||||||
_apiClient.addDefaultHeader('Authorization', 'Bearer $accessToken');
|
_apiClient.addDefaultHeader('Authorization', 'Bearer $accessToken');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApiClient get apiClient => _apiClient;
|
||||||
}
|
}
|
||||||
|
@ -1,101 +1,84 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hive/hive.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
|
||||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
|
||||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
|
||||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||||
import 'package:immich_mobile/utils/openapi_extensions.dart';
|
import 'package:immich_mobile/utils/openapi_extensions.dart';
|
||||||
import 'package:immich_mobile/utils/tuple.dart';
|
import 'package:immich_mobile/utils/tuple.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
final assetServiceProvider = Provider(
|
final assetServiceProvider = Provider(
|
||||||
(ref) => AssetService(
|
(ref) => AssetService(
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
ref.watch(backupServiceProvider),
|
ref.watch(syncServiceProvider),
|
||||||
ref.watch(backgroundServiceProvider),
|
ref.watch(dbProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class AssetService {
|
class AssetService {
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
final BackupService _backupService;
|
final SyncService _syncService;
|
||||||
final BackgroundService _backgroundService;
|
|
||||||
final log = Logger('AssetService');
|
final log = Logger('AssetService');
|
||||||
|
final Isar _db;
|
||||||
|
|
||||||
AssetService(this._apiService, this._backupService, this._backgroundService);
|
AssetService(
|
||||||
|
this._apiService,
|
||||||
|
this._syncService,
|
||||||
|
this._db,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Checks the server for updated assets and updates the local database if
|
||||||
|
/// required. Returns `true` if there were any changes.
|
||||||
|
Future<bool> refreshRemoteAssets() async {
|
||||||
|
final Stopwatch sw = Stopwatch()..start();
|
||||||
|
final int numOwnedRemoteAssets = await _db.assets
|
||||||
|
.where()
|
||||||
|
.remoteIdIsNotNull()
|
||||||
|
.filter()
|
||||||
|
.ownerIdEqualTo(Store.get<User>(StoreKey.currentUser)!.isarId)
|
||||||
|
.count();
|
||||||
|
final List<AssetResponseDto>? dtos =
|
||||||
|
await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0);
|
||||||
|
if (dtos == null) {
|
||||||
|
debugPrint("refreshRemoteAssets fast took ${sw.elapsedMilliseconds}ms");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final bool changes = await _syncService
|
||||||
|
.syncRemoteAssetsToDb(dtos.map(Asset.remote).toList());
|
||||||
|
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `null` if the server state did not change, else list of assets
|
/// Returns `null` if the server state did not change, else list of assets
|
||||||
Future<Pair<List<Asset>?, String?>> getRemoteAssets({String? etag}) async {
|
Future<List<AssetResponseDto>?> _getRemoteAssets({
|
||||||
|
required bool hasCache,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
// temporary fix for race condition that the _apiService
|
final etag = hasCache ? Store.get(StoreKey.assetETag) : null;
|
||||||
// get called before accessToken is set
|
|
||||||
var userInfoHiveBox = await Hive.openBox(userInfoBox);
|
|
||||||
var accessToken = userInfoHiveBox.get(accessTokenKey);
|
|
||||||
_apiService.setAccessToken(accessToken);
|
|
||||||
|
|
||||||
final Pair<List<AssetResponseDto>, String?>? remote =
|
final Pair<List<AssetResponseDto>, String?>? remote =
|
||||||
await _apiService.assetApi.getAllAssetsWithETag(eTag: etag);
|
await _apiService.assetApi.getAllAssetsWithETag(eTag: etag);
|
||||||
if (remote == null) {
|
if (remote == null) {
|
||||||
return Pair(null, etag);
|
return null;
|
||||||
}
|
}
|
||||||
return Pair(
|
if (remote.second != null && remote.second != etag) {
|
||||||
remote.first.map(Asset.remote).toList(growable: false),
|
Store.put(StoreKey.assetETag, remote.second);
|
||||||
remote.second,
|
}
|
||||||
);
|
return remote.first;
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
log.severe('Error while getting remote assets', e, stack);
|
log.severe('Error while getting remote assets', e, stack);
|
||||||
debugPrint("[ERROR] [getRemoteAssets] $e");
|
return null;
|
||||||
return Pair(null, etag);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// if [urgent] is `true`, do not block by waiting on the background service
|
|
||||||
/// to finish running. Returns `null` instead after a timeout.
|
|
||||||
Future<List<Asset>?> getLocalAssets({bool urgent = false}) async {
|
|
||||||
try {
|
|
||||||
final Future<bool> hasAccess = urgent
|
|
||||||
? _backgroundService.hasAccess
|
|
||||||
.timeout(const Duration(milliseconds: 250))
|
|
||||||
: _backgroundService.hasAccess;
|
|
||||||
if (!await hasAccess) {
|
|
||||||
throw Exception("Error [getAllAsset] failed to gain access");
|
|
||||||
}
|
|
||||||
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
|
||||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
|
||||||
final String userId = Store.get(StoreKey.userRemoteId);
|
|
||||||
if (backupAlbumInfo != null) {
|
|
||||||
return (await _backupService
|
|
||||||
.buildUploadCandidates(backupAlbumInfo.deepCopy()))
|
|
||||||
.map((e) => Asset.local(e, userId))
|
|
||||||
.toList(growable: false);
|
|
||||||
}
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
log.severe('Error while getting local assets', e, stackTrace);
|
|
||||||
debugPrint("Error [_getLocalAssets] ${e.toString()}");
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Asset?> getAssetById(String assetId) async {
|
|
||||||
try {
|
|
||||||
final dto = await _apiService.assetApi.getAssetById(assetId);
|
|
||||||
if (dto != null) {
|
|
||||||
return Asset.remote(dto);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error [getAssetById] ${e.toString()}");
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<DeleteAssetResponseDto>?> deleteAssets(
|
Future<List<DeleteAssetResponseDto>?> deleteAssets(
|
||||||
Iterable<Asset> deleteAssets,
|
Iterable<Asset> deleteAssets,
|
||||||
) async {
|
) async {
|
||||||
@ -114,6 +97,28 @@ class AssetService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads the exif information from the database. If there is none, loads
|
||||||
|
/// the exif info from the server (remote assets only)
|
||||||
|
Future<Asset> loadExif(Asset a) async {
|
||||||
|
a.exifInfo ??= await _db.exifInfos.get(a.id);
|
||||||
|
if (a.exifInfo?.iso == null) {
|
||||||
|
if (a.isRemote) {
|
||||||
|
final dto = await _apiService.assetApi.getAssetById(a.remoteId!);
|
||||||
|
if (dto != null && dto.exifInfo != null) {
|
||||||
|
a = a.withUpdatesFromDto(dto);
|
||||||
|
if (a.isInDb) {
|
||||||
|
_db.writeTxn(() => a.put(_db));
|
||||||
|
} else {
|
||||||
|
debugPrint("[loadExif] parameter Asset is not from DB!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO implement local exif info parsing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Asset?> updateAsset(
|
Future<Asset?> updateAsset(
|
||||||
Asset asset,
|
Asset asset,
|
||||||
UpdateAssetDto updateAssetDto,
|
UpdateAssetDto updateAssetDto,
|
||||||
|
@ -1,41 +1,13 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/services/json_cache.dart';
|
import 'package:immich_mobile/shared/services/json_cache.dart';
|
||||||
|
|
||||||
|
@Deprecated("only kept to remove its files after migration")
|
||||||
class AssetCacheService extends JsonCache<List<Asset>> {
|
class AssetCacheService extends JsonCache<List<Asset>> {
|
||||||
AssetCacheService() : super("asset_cache");
|
AssetCacheService() : super("asset_cache");
|
||||||
|
|
||||||
static Future<List<Map<String, dynamic>>> _computeSerialize(
|
@override
|
||||||
List<Asset> assets,
|
void put(List<Asset> data) {}
|
||||||
) async {
|
|
||||||
return assets.map((e) => e.toJson()).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void put(List<Asset> data) async {
|
Future<List<Asset>?> get() => Future.value(null);
|
||||||
putRawData(await compute(_computeSerialize, data));
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<List<Asset>> _computeEncode(List<dynamic> data) async {
|
|
||||||
return data.map((e) => Asset.fromJson(e)).whereNotNull().toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<List<Asset>?> get() async {
|
|
||||||
try {
|
|
||||||
final mapList = await readRawData() as List<dynamic>;
|
|
||||||
final responseData = await compute(_computeEncode, mapList);
|
|
||||||
return responseData;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint(e.toString());
|
|
||||||
await invalidate();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final assetCacheServiceProvider = Provider(
|
|
||||||
(ref) => AssetCacheService(),
|
|
||||||
);
|
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
@Deprecated("only kept to remove its files after migration")
|
||||||
abstract class JsonCache<T> {
|
abstract class JsonCache<T> {
|
||||||
final String cacheFileName;
|
final String cacheFileName;
|
||||||
|
|
||||||
@ -32,33 +31,6 @@ abstract class JsonCache<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<String> _computeEncodeJson(dynamic toEncode) async {
|
|
||||||
return json.encode(toEncode);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> putRawData(dynamic data) async {
|
|
||||||
final jsonString = await compute(_computeEncodeJson, data);
|
|
||||||
|
|
||||||
final file = await _getCacheFile();
|
|
||||||
|
|
||||||
if (!await file.exists()) {
|
|
||||||
await file.create();
|
|
||||||
}
|
|
||||||
|
|
||||||
await file.writeAsString(jsonString);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<dynamic> _computeDecodeJson(String jsonString) async {
|
|
||||||
return json.decode(jsonString);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<dynamic> readRawData() async {
|
|
||||||
final file = await _getCacheFile();
|
|
||||||
final data = await file.readAsString();
|
|
||||||
|
|
||||||
return await compute(_computeDecodeJson, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
void put(T data);
|
void put(T data);
|
||||||
Future<T?> get();
|
Future<T?> get();
|
||||||
}
|
}
|
||||||
|
558
mobile/lib/shared/services/sync.service.dart
Normal file
558
mobile/lib/shared/services/sync.service.dart
Normal file
@ -0,0 +1,558 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/album.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
|
import 'package:immich_mobile/utils/tuple.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
final syncServiceProvider =
|
||||||
|
Provider((ref) => SyncService(ref.watch(dbProvider)));
|
||||||
|
|
||||||
|
class SyncService {
|
||||||
|
final Isar _db;
|
||||||
|
final AsyncMutex _lock = AsyncMutex();
|
||||||
|
|
||||||
|
SyncService(this._db);
|
||||||
|
|
||||||
|
// public methods:
|
||||||
|
|
||||||
|
/// Syncs users from the server to the local database
|
||||||
|
/// Returns `true`if there were any changes
|
||||||
|
Future<bool> syncUsersFromServer(List<User> users) async {
|
||||||
|
users.sortBy((u) => u.id);
|
||||||
|
final dbUsers = await _db.users.where().sortById().findAll();
|
||||||
|
final List<int> toDelete = [];
|
||||||
|
final List<User> toUpsert = [];
|
||||||
|
final changes = diffSortedListsSync(
|
||||||
|
users,
|
||||||
|
dbUsers,
|
||||||
|
compare: (User a, User b) => a.id.compareTo(b.id),
|
||||||
|
both: (User a, User b) {
|
||||||
|
if (a.updatedAt != b.updatedAt) {
|
||||||
|
toUpsert.add(a);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onlyFirst: (User a) => toUpsert.add(a),
|
||||||
|
onlySecond: (User b) => toDelete.add(b.isarId),
|
||||||
|
);
|
||||||
|
if (changes) {
|
||||||
|
await _db.writeTxn(() async {
|
||||||
|
await _db.users.deleteAll(toDelete);
|
||||||
|
await _db.users.putAll(toUpsert);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syncs remote assets owned by the logged-in user to the DB
|
||||||
|
/// Returns `true` if there were any changes
|
||||||
|
Future<bool> syncRemoteAssetsToDb(List<Asset> remote) =>
|
||||||
|
_lock.run(() => _syncRemoteAssetsToDb(remote));
|
||||||
|
|
||||||
|
/// Syncs remote albums to the database
|
||||||
|
/// returns `true` if there were any changes
|
||||||
|
Future<bool> syncRemoteAlbumsToDb(
|
||||||
|
List<AlbumResponseDto> remote, {
|
||||||
|
required bool isShared,
|
||||||
|
required FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||||
|
}) =>
|
||||||
|
_lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails));
|
||||||
|
|
||||||
|
/// Syncs all device albums and their assets to the database
|
||||||
|
/// Returns `true` if there were any changes
|
||||||
|
Future<bool> syncLocalAlbumAssetsToDb(List<AssetPathEntity> onDevice) =>
|
||||||
|
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice));
|
||||||
|
|
||||||
|
/// returns all Asset IDs that are not contained in the existing list
|
||||||
|
List<int> sharedAssetsToRemove(
|
||||||
|
List<Asset> deleteCandidates,
|
||||||
|
List<Asset> existing,
|
||||||
|
) {
|
||||||
|
if (deleteCandidates.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
deleteCandidates.sort(Asset.compareById);
|
||||||
|
existing.sort(Asset.compareById);
|
||||||
|
return _diffAssets(existing, deleteCandidates, compare: Asset.compareById)
|
||||||
|
.third
|
||||||
|
.map((e) => e.id)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// private methods:
|
||||||
|
|
||||||
|
/// Syncs remote assets to the databas
|
||||||
|
/// returns `true` if there were any changes
|
||||||
|
Future<bool> _syncRemoteAssetsToDb(List<Asset> remote) async {
|
||||||
|
final User user = Store.get(StoreKey.currentUser);
|
||||||
|
final List<Asset> inDb = await _db.assets
|
||||||
|
.filter()
|
||||||
|
.ownerIdEqualTo(user.isarId)
|
||||||
|
.sortByDeviceId()
|
||||||
|
.thenByLocalId()
|
||||||
|
.findAll();
|
||||||
|
remote.sort(Asset.compareByDeviceIdLocalId);
|
||||||
|
final diff = _diffAssets(remote, inDb, remote: true);
|
||||||
|
if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final idsToDelete = diff.third.map((e) => e.id).toList();
|
||||||
|
try {
|
||||||
|
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
|
||||||
|
await _upsertAssetsWithExif(diff.first + diff.second);
|
||||||
|
} on IsarError catch (e) {
|
||||||
|
debugPrint(e.toString());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syncs remote albums to the database
|
||||||
|
/// returns `true` if there were any changes
|
||||||
|
Future<bool> _syncRemoteAlbumsToDb(
|
||||||
|
List<AlbumResponseDto> remote,
|
||||||
|
bool isShared,
|
||||||
|
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||||
|
) async {
|
||||||
|
remote.sortBy((e) => e.id);
|
||||||
|
|
||||||
|
final baseQuery = _db.albums.where().remoteIdIsNotNull().filter();
|
||||||
|
final QueryBuilder<Album, Album, QAfterFilterCondition> query;
|
||||||
|
if (isShared) {
|
||||||
|
query = baseQuery.sharedEqualTo(true);
|
||||||
|
} else {
|
||||||
|
final User me = Store.get(StoreKey.currentUser);
|
||||||
|
query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId));
|
||||||
|
}
|
||||||
|
final List<Album> dbAlbums = await query.sortByRemoteId().findAll();
|
||||||
|
|
||||||
|
final List<Asset> toDelete = [];
|
||||||
|
final List<Asset> existing = [];
|
||||||
|
|
||||||
|
final bool changes = await diffSortedLists(
|
||||||
|
remote,
|
||||||
|
dbAlbums,
|
||||||
|
compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!),
|
||||||
|
both: (AlbumResponseDto a, Album b) =>
|
||||||
|
_syncRemoteAlbum(a, b, toDelete, existing, loadDetails),
|
||||||
|
onlyFirst: (AlbumResponseDto a) =>
|
||||||
|
_addAlbumFromServer(a, existing, loadDetails),
|
||||||
|
onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isShared && toDelete.isNotEmpty) {
|
||||||
|
final List<int> idsToRemove = sharedAssetsToRemove(toDelete, existing);
|
||||||
|
if (idsToRemove.isNotEmpty) {
|
||||||
|
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert(toDelete.isEmpty);
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// syncs albums from the server to the local database (does not support
|
||||||
|
/// syncing changes from local back to server)
|
||||||
|
/// accumulates
|
||||||
|
Future<bool> _syncRemoteAlbum(
|
||||||
|
AlbumResponseDto dto,
|
||||||
|
Album album,
|
||||||
|
List<Asset> deleteCandidates,
|
||||||
|
List<Asset> existing,
|
||||||
|
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||||
|
) async {
|
||||||
|
if (!_hasAlbumResponseDtoChanged(dto, album)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
dto = await loadDetails(dto);
|
||||||
|
if (dto.assetCount != dto.assets.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final assetsInDb =
|
||||||
|
await album.assets.filter().sortByDeviceId().thenByLocalId().findAll();
|
||||||
|
final List<Asset> assetsOnRemote = dto.getAssets();
|
||||||
|
assetsOnRemote.sort(Asset.compareByDeviceIdLocalId);
|
||||||
|
final d = _diffAssets(assetsOnRemote, assetsInDb);
|
||||||
|
final List<Asset> toAdd = d.first, toUpdate = d.second, toUnlink = d.third;
|
||||||
|
|
||||||
|
// update shared users
|
||||||
|
final List<User> sharedUsers = album.sharedUsers.toList(growable: false);
|
||||||
|
sharedUsers.sort((a, b) => a.id.compareTo(b.id));
|
||||||
|
dto.sharedUsers.sort((a, b) => a.id.compareTo(b.id));
|
||||||
|
final List<String> userIdsToAdd = [];
|
||||||
|
final List<User> usersToUnlink = [];
|
||||||
|
diffSortedListsSync(
|
||||||
|
dto.sharedUsers,
|
||||||
|
sharedUsers,
|
||||||
|
compare: (UserResponseDto a, User b) => a.id.compareTo(b.id),
|
||||||
|
both: (a, b) => false,
|
||||||
|
onlyFirst: (UserResponseDto a) => userIdsToAdd.add(a.id),
|
||||||
|
onlySecond: (User a) => usersToUnlink.add(a),
|
||||||
|
);
|
||||||
|
|
||||||
|
// for shared album: put missing album assets into local DB
|
||||||
|
final resultPair = await _linkWithExistingFromDb(toAdd);
|
||||||
|
await _upsertAssetsWithExif(resultPair.second);
|
||||||
|
final assetsToLink = resultPair.first + resultPair.second;
|
||||||
|
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
|
||||||
|
|
||||||
|
album.name = dto.albumName;
|
||||||
|
album.shared = dto.shared;
|
||||||
|
album.modifiedAt = DateTime.parse(dto.updatedAt).toUtc();
|
||||||
|
if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) {
|
||||||
|
album.thumbnail.value = await _db.assets
|
||||||
|
.where()
|
||||||
|
.remoteIdEqualTo(dto.albumThumbnailAssetId)
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
// write & commit all changes to DB
|
||||||
|
try {
|
||||||
|
await _db.writeTxn(() async {
|
||||||
|
await _db.assets.putAll(toUpdate);
|
||||||
|
await album.thumbnail.save();
|
||||||
|
await album.sharedUsers
|
||||||
|
.update(link: usersToLink, unlink: usersToUnlink);
|
||||||
|
await album.assets.update(link: assetsToLink, unlink: toUnlink.cast());
|
||||||
|
await _db.albums.put(album);
|
||||||
|
});
|
||||||
|
} on IsarError catch (e) {
|
||||||
|
debugPrint(e.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (album.shared || dto.shared) {
|
||||||
|
final userId = Store.get<User>(StoreKey.currentUser)!.isarId;
|
||||||
|
final foreign =
|
||||||
|
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
|
||||||
|
existing.addAll(foreign);
|
||||||
|
|
||||||
|
// delete assets in DB unless they belong to this user or part of some other shared album
|
||||||
|
deleteCandidates.addAll(toUnlink.where((a) => a.ownerId != userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a remote album to the database while making sure to add any foreign
|
||||||
|
/// (shared) assets to the database beforehand
|
||||||
|
/// accumulates assets already existing in the database
|
||||||
|
Future<void> _addAlbumFromServer(
|
||||||
|
AlbumResponseDto dto,
|
||||||
|
List<Asset> existing,
|
||||||
|
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||||
|
) async {
|
||||||
|
if (dto.assetCount != dto.assets.length) {
|
||||||
|
dto = await loadDetails(dto);
|
||||||
|
}
|
||||||
|
if (dto.assetCount == dto.assets.length) {
|
||||||
|
// in case an album contains assets not yet present in local DB:
|
||||||
|
// put missing album assets into local DB
|
||||||
|
final result = await _linkWithExistingFromDb(dto.getAssets());
|
||||||
|
existing.addAll(result.first);
|
||||||
|
await _upsertAssetsWithExif(result.second);
|
||||||
|
|
||||||
|
final Album a = await Album.remote(dto);
|
||||||
|
await _db.writeTxn(() => _db.albums.store(a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulates all suitable album assets to the `deleteCandidates` and
|
||||||
|
/// removes the album from the database.
|
||||||
|
Future<void> _removeAlbumFromDb(
|
||||||
|
Album album,
|
||||||
|
List<Asset> deleteCandidates,
|
||||||
|
) async {
|
||||||
|
if (album.isLocal) {
|
||||||
|
// delete assets in DB unless they are remote or part of some other album
|
||||||
|
deleteCandidates.addAll(
|
||||||
|
await album.assets.filter().remoteIdIsNull().findAll(),
|
||||||
|
);
|
||||||
|
} else if (album.shared) {
|
||||||
|
final User user = Store.get(StoreKey.currentUser);
|
||||||
|
// delete assets in DB unless they belong to this user or are part of some other shared album
|
||||||
|
deleteCandidates.addAll(
|
||||||
|
await album.assets.filter().not().ownerIdEqualTo(user.isarId).findAll(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||||
|
assert(ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syncs all device albums and their assets to the database
|
||||||
|
/// Returns `true` if there were any changes
|
||||||
|
Future<bool> _syncLocalAlbumAssetsToDb(List<AssetPathEntity> onDevice) async {
|
||||||
|
onDevice.sort((a, b) => a.id.compareTo(b.id));
|
||||||
|
final List<Album> inDb =
|
||||||
|
await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll();
|
||||||
|
final List<Asset> deleteCandidates = [];
|
||||||
|
final List<Asset> existing = [];
|
||||||
|
final bool anyChanges = await diffSortedLists(
|
||||||
|
onDevice,
|
||||||
|
inDb,
|
||||||
|
compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!),
|
||||||
|
both: (AssetPathEntity ape, Album album) =>
|
||||||
|
_syncAlbumInDbAndOnDevice(ape, album, deleteCandidates, existing),
|
||||||
|
onlyFirst: (AssetPathEntity ape) => _addAlbumFromDevice(ape, existing),
|
||||||
|
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
|
||||||
|
);
|
||||||
|
final pair = _handleAssetRemoval(deleteCandidates, existing);
|
||||||
|
if (pair.first.isNotEmpty || pair.second.isNotEmpty) {
|
||||||
|
await _db.writeTxn(() async {
|
||||||
|
await _db.assets.deleteAll(pair.first);
|
||||||
|
await _db.assets.putAll(pair.second);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return anyChanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syncs the device album to the album in the database
|
||||||
|
/// returns `true` if there were any changes
|
||||||
|
/// Accumulates asset candidates to delete and those already existing in DB
|
||||||
|
Future<bool> _syncAlbumInDbAndOnDevice(
|
||||||
|
AssetPathEntity ape,
|
||||||
|
Album album,
|
||||||
|
List<Asset> deleteCandidates,
|
||||||
|
List<Asset> existing, [
|
||||||
|
bool forceRefresh = false,
|
||||||
|
]) async {
|
||||||
|
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!forceRefresh && await _syncDeviceAlbumFast(ape, album)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// general case, e.g. some assets have been deleted
|
||||||
|
final inDb = await album.assets.filter().sortByLocalId().findAll();
|
||||||
|
final List<Asset> onDevice = await ape.getAssets();
|
||||||
|
onDevice.sort(Asset.compareByLocalId);
|
||||||
|
final d = _diffAssets(onDevice, inDb, compare: Asset.compareByLocalId);
|
||||||
|
final List<Asset> toAdd = d.first, toUpdate = d.second, toDelete = d.third;
|
||||||
|
final result = await _linkWithExistingFromDb(toAdd);
|
||||||
|
deleteCandidates.addAll(toDelete);
|
||||||
|
existing.addAll(result.first);
|
||||||
|
album.name = ape.name;
|
||||||
|
album.modifiedAt = ape.lastModified!;
|
||||||
|
if (album.thumbnail.value != null &&
|
||||||
|
toDelete.contains(album.thumbnail.value)) {
|
||||||
|
album.thumbnail.value = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _db.writeTxn(() async {
|
||||||
|
await _db.assets.putAll(result.second);
|
||||||
|
await _db.assets.putAll(toUpdate);
|
||||||
|
await album.assets
|
||||||
|
.update(link: result.first + result.second, unlink: toDelete);
|
||||||
|
await _db.albums.put(album);
|
||||||
|
album.thumbnail.value ??= await album.assets.filter().findFirst();
|
||||||
|
await album.thumbnail.save();
|
||||||
|
});
|
||||||
|
} on IsarError catch (e) {
|
||||||
|
debugPrint(e.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// fast path for common case: only new assets were added to device album
|
||||||
|
/// returns `true` if successfull, else `false`
|
||||||
|
Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async {
|
||||||
|
final int totalOnDevice = await ape.assetCountAsync;
|
||||||
|
final AssetPathEntity? modified = totalOnDevice > album.assetCount
|
||||||
|
? await ape.fetchPathProperties(
|
||||||
|
filterOptionGroup: FilterOptionGroup(
|
||||||
|
updateTimeCond: DateTimeCond(
|
||||||
|
min: album.modifiedAt.add(const Duration(seconds: 1)),
|
||||||
|
max: ape.lastModified!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (modified == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final List<Asset> newAssets = await modified.getAssets();
|
||||||
|
if (totalOnDevice != album.assets.length + newAssets.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
album.modifiedAt = ape.lastModified!.toUtc();
|
||||||
|
final result = await _linkWithExistingFromDb(newAssets);
|
||||||
|
try {
|
||||||
|
await _db.writeTxn(() async {
|
||||||
|
await _db.assets.putAll(result.second);
|
||||||
|
await album.assets.update(link: result.first + result.second);
|
||||||
|
await _db.albums.put(album);
|
||||||
|
});
|
||||||
|
} on IsarError catch (e) {
|
||||||
|
debugPrint(e.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a new album from the device to the database and Accumulates all
|
||||||
|
/// assets already existing in the database to the list of `existing` assets
|
||||||
|
Future<void> _addAlbumFromDevice(
|
||||||
|
AssetPathEntity ape,
|
||||||
|
List<Asset> existing,
|
||||||
|
) async {
|
||||||
|
final Album a = Album.local(ape);
|
||||||
|
final result = await _linkWithExistingFromDb(await ape.getAssets());
|
||||||
|
await _upsertAssetsWithExif(result.second);
|
||||||
|
existing.addAll(result.first);
|
||||||
|
a.assets.addAll(result.first);
|
||||||
|
a.assets.addAll(result.second);
|
||||||
|
final thumb = result.first.firstOrNull ?? result.second.firstOrNull;
|
||||||
|
a.thumbnail.value = thumb;
|
||||||
|
try {
|
||||||
|
await _db.writeTxn(() => _db.albums.store(a));
|
||||||
|
} on IsarError catch (e) {
|
||||||
|
debugPrint(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a tuple (existing, updated)
|
||||||
|
Future<Pair<List<Asset>, List<Asset>>> _linkWithExistingFromDb(
|
||||||
|
List<Asset> assets,
|
||||||
|
) async {
|
||||||
|
if (assets.isEmpty) {
|
||||||
|
return const Pair([], []);
|
||||||
|
}
|
||||||
|
final List<Asset> inDb = await _db.assets
|
||||||
|
.where()
|
||||||
|
.anyOf(
|
||||||
|
assets,
|
||||||
|
(q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId),
|
||||||
|
)
|
||||||
|
.sortByDeviceId()
|
||||||
|
.thenByLocalId()
|
||||||
|
.findAll();
|
||||||
|
assets.sort(Asset.compareByDeviceIdLocalId);
|
||||||
|
final List<Asset> existing = [], toUpsert = [];
|
||||||
|
diffSortedListsSync(
|
||||||
|
inDb,
|
||||||
|
assets,
|
||||||
|
compare: Asset.compareByDeviceIdLocalId,
|
||||||
|
both: (Asset a, Asset b) {
|
||||||
|
if ((a.isLocal || !b.isLocal) &&
|
||||||
|
(a.isRemote || !b.isRemote) &&
|
||||||
|
a.updatedAt == b.updatedAt) {
|
||||||
|
existing.add(a);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
toUpsert.add(b.updateFromDb(a));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onlyFirst: (Asset a) => throw Exception("programming error"),
|
||||||
|
onlySecond: (Asset b) => toUpsert.add(b),
|
||||||
|
);
|
||||||
|
return Pair(existing, toUpsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
||||||
|
Future<void> _upsertAssetsWithExif(List<Asset> assets) async {
|
||||||
|
if (assets.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList();
|
||||||
|
try {
|
||||||
|
await _db.writeTxn(() async {
|
||||||
|
await _db.assets.putAll(assets);
|
||||||
|
for (final Asset added in assets) {
|
||||||
|
added.exifInfo?.id = added.id;
|
||||||
|
}
|
||||||
|
await _db.exifInfos.putAll(exifInfos);
|
||||||
|
});
|
||||||
|
} on IsarError catch (e) {
|
||||||
|
debugPrint(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a triple(toAdd, toUpdate, toRemove)
|
||||||
|
Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
|
||||||
|
List<Asset> assets,
|
||||||
|
List<Asset> inDb, {
|
||||||
|
bool? remote,
|
||||||
|
int Function(Asset, Asset) compare = Asset.compareByDeviceIdLocalId,
|
||||||
|
}) {
|
||||||
|
final List<Asset> toAdd = [];
|
||||||
|
final List<Asset> toUpdate = [];
|
||||||
|
final List<Asset> toRemove = [];
|
||||||
|
diffSortedListsSync(
|
||||||
|
inDb,
|
||||||
|
assets,
|
||||||
|
compare: compare,
|
||||||
|
both: (Asset a, Asset b) {
|
||||||
|
if (a.updatedAt.isBefore(b.updatedAt) ||
|
||||||
|
(!a.isLocal && b.isLocal) ||
|
||||||
|
(!a.isRemote && b.isRemote)) {
|
||||||
|
toUpdate.add(b.updateFromDb(a));
|
||||||
|
debugPrint("both");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onlyFirst: (Asset a) {
|
||||||
|
if (remote == true && a.isLocal) {
|
||||||
|
if (a.remoteId != null) {
|
||||||
|
a.remoteId = null;
|
||||||
|
toUpdate.add(a);
|
||||||
|
}
|
||||||
|
} else if (remote == false && a.isRemote) {
|
||||||
|
if (a.isLocal) {
|
||||||
|
a.isLocal = false;
|
||||||
|
toUpdate.add(a);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toRemove.add(a);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onlySecond: (Asset b) => toAdd.add(b),
|
||||||
|
);
|
||||||
|
return Triple(toAdd, toUpdate, toRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
|
||||||
|
Pair<List<int>, List<Asset>> _handleAssetRemoval(
|
||||||
|
List<Asset> deleteCandidates,
|
||||||
|
List<Asset> existing,
|
||||||
|
) {
|
||||||
|
if (deleteCandidates.isEmpty) {
|
||||||
|
return const Pair([], []);
|
||||||
|
}
|
||||||
|
deleteCandidates.sort(Asset.compareById);
|
||||||
|
existing.sort(Asset.compareById);
|
||||||
|
final triple =
|
||||||
|
_diffAssets(existing, deleteCandidates, compare: Asset.compareById);
|
||||||
|
return Pair(triple.third.map((e) => e.id).toList(), triple.second);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns `true` if the albums differ on the surface
|
||||||
|
Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async {
|
||||||
|
return a.name != b.name ||
|
||||||
|
a.lastModified != b.modifiedAt ||
|
||||||
|
await a.assetCountAsync != b.assetCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns `true` if the albums differ on the surface
|
||||||
|
bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) {
|
||||||
|
return dto.assetCount != a.assetCount ||
|
||||||
|
dto.albumName != a.name ||
|
||||||
|
dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId ||
|
||||||
|
dto.shared != a.shared ||
|
||||||
|
DateTime.parse(dto.updatedAt).toUtc() != a.modifiedAt.toUtc();
|
||||||
|
}
|
@ -3,24 +3,32 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:http_parser/http_parser.dart';
|
import 'package:http_parser/http_parser.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
import 'package:immich_mobile/shared/models/user.dart';
|
import 'package:immich_mobile/shared/models/user.dart';
|
||||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||||
import 'package:immich_mobile/utils/files_helper.dart';
|
import 'package:immich_mobile/utils/files_helper.dart';
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
final userServiceProvider = Provider(
|
final userServiceProvider = Provider(
|
||||||
(ref) => UserService(
|
(ref) => UserService(
|
||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
|
ref.watch(dbProvider),
|
||||||
|
ref.watch(syncServiceProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
class UserService {
|
class UserService {
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
|
final Isar _db;
|
||||||
|
final SyncService _syncService;
|
||||||
|
|
||||||
UserService(this._apiService);
|
UserService(this._apiService, this._db, this._syncService);
|
||||||
|
|
||||||
Future<List<User>?> getAllUsers({required bool isAll}) async {
|
Future<List<User>?> _getAllUsers({required bool isAll}) async {
|
||||||
try {
|
try {
|
||||||
final dto = await _apiService.userApi.getAllUsers(isAll);
|
final dto = await _apiService.userApi.getAllUsers(isAll);
|
||||||
return dto?.map(User.fromDto).toList();
|
return dto?.map(User.fromDto).toList();
|
||||||
@ -30,6 +38,14 @@ class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<User>> getUsersInDb({bool self = false}) async {
|
||||||
|
if (self) {
|
||||||
|
return _db.users.where().findAll();
|
||||||
|
}
|
||||||
|
final int userId = Store.get<User>(StoreKey.currentUser)!.isarId;
|
||||||
|
return _db.users.where().isarIdNotEqualTo(userId).findAll();
|
||||||
|
}
|
||||||
|
|
||||||
Future<CreateProfileImageResponseDto?> uploadProfileImage(XFile image) async {
|
Future<CreateProfileImageResponseDto?> uploadProfileImage(XFile image) async {
|
||||||
try {
|
try {
|
||||||
var mimeType = FileHelper.getMimeType(image.path);
|
var mimeType = FileHelper.getMimeType(image.path);
|
||||||
@ -50,4 +66,12 @@ class UserService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> refreshUsers() async {
|
||||||
|
final List<User>? users = await _getAllUsers(isAll: true);
|
||||||
|
if (users == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return _syncService.syncUsersFromServer(users);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
16
mobile/lib/utils/async_mutex.dart
Normal file
16
mobile/lib/utils/async_mutex.dart
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// Async mutex to guarantee actions are performed sequentially and do not interleave
|
||||||
|
class AsyncMutex {
|
||||||
|
Future _running = Future.value(null);
|
||||||
|
|
||||||
|
/// Execute [operation] exclusively, after any currently running operations.
|
||||||
|
/// Returns a [Future] with the result of the [operation].
|
||||||
|
Future<T> run<T>(Future<T> Function() operation) {
|
||||||
|
final completer = Completer<T>();
|
||||||
|
_running.whenComplete(() {
|
||||||
|
completer.complete(Future<T>.sync(operation));
|
||||||
|
});
|
||||||
|
return _running = completer.future;
|
||||||
|
}
|
||||||
|
}
|
@ -5,7 +5,11 @@ extension DurationExtension on String {
|
|||||||
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);
|
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
double? toDouble() {
|
double toDouble() {
|
||||||
return double.tryParse(this);
|
return double.parse(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
int toInt() {
|
||||||
|
return int.parse(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
71
mobile/lib/utils/diff.dart
Normal file
71
mobile/lib/utils/diff.dart
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// Efficiently compares two sorted lists in O(n), calling the given callback
|
||||||
|
/// for each item.
|
||||||
|
/// Return `true` if there are any differences found, else `false`
|
||||||
|
Future<bool> diffSortedLists<A, B>(
|
||||||
|
List<A> la,
|
||||||
|
List<B> lb, {
|
||||||
|
required int Function(A a, B b) compare,
|
||||||
|
required FutureOr<bool> Function(A a, B b) both,
|
||||||
|
required FutureOr<void> Function(A a) onlyFirst,
|
||||||
|
required FutureOr<void> Function(B b) onlySecond,
|
||||||
|
}) async {
|
||||||
|
bool diff = false;
|
||||||
|
int i = 0, j = 0;
|
||||||
|
for (; i < la.length && j < lb.length;) {
|
||||||
|
final int order = compare(la[i], lb[j]);
|
||||||
|
if (order == 0) {
|
||||||
|
diff |= await both(la[i++], lb[j++]);
|
||||||
|
} else if (order < 0) {
|
||||||
|
await onlyFirst(la[i++]);
|
||||||
|
diff = true;
|
||||||
|
} else if (order > 0) {
|
||||||
|
await onlySecond(lb[j++]);
|
||||||
|
diff = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff |= i < la.length || j < lb.length;
|
||||||
|
for (; i < la.length; i++) {
|
||||||
|
await onlyFirst(la[i]);
|
||||||
|
}
|
||||||
|
for (; j < lb.length; j++) {
|
||||||
|
await onlySecond(lb[j]);
|
||||||
|
}
|
||||||
|
return diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Efficiently compares two sorted lists in O(n), calling the given callback
|
||||||
|
/// for each item.
|
||||||
|
/// Return `true` if there are any differences found, else `false`
|
||||||
|
bool diffSortedListsSync<A, B>(
|
||||||
|
List<A> la,
|
||||||
|
List<B> lb, {
|
||||||
|
required int Function(A a, B b) compare,
|
||||||
|
required bool Function(A a, B b) both,
|
||||||
|
required void Function(A a) onlyFirst,
|
||||||
|
required void Function(B b) onlySecond,
|
||||||
|
}) {
|
||||||
|
bool diff = false;
|
||||||
|
int i = 0, j = 0;
|
||||||
|
for (; i < la.length && j < lb.length;) {
|
||||||
|
final int order = compare(la[i], lb[j]);
|
||||||
|
if (order == 0) {
|
||||||
|
diff |= both(la[i++], lb[j++]);
|
||||||
|
} else if (order < 0) {
|
||||||
|
onlyFirst(la[i++]);
|
||||||
|
diff = true;
|
||||||
|
} else if (order > 0) {
|
||||||
|
onlySecond(lb[j++]);
|
||||||
|
diff = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff |= i < la.length || j < lb.length;
|
||||||
|
for (; i < la.length; i++) {
|
||||||
|
onlyFirst(la[i]);
|
||||||
|
}
|
||||||
|
for (; j < lb.length; j++) {
|
||||||
|
onlySecond(lb[j]);
|
||||||
|
}
|
||||||
|
return diff;
|
||||||
|
}
|
15
mobile/lib/utils/hash.dart
Normal file
15
mobile/lib/utils/hash.dart
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/// FNV-1a 64bit hash algorithm optimized for Dart Strings
|
||||||
|
int fastHash(String string) {
|
||||||
|
var hash = 0xcbf29ce484222325;
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
while (i < string.length) {
|
||||||
|
final codeUnit = string.codeUnitAt(i++);
|
||||||
|
hash ^= codeUnit >> 8;
|
||||||
|
hash *= 0x100000001b3;
|
||||||
|
hash ^= codeUnit & 0xFF;
|
||||||
|
hash *= 0x100000001b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
@ -31,20 +31,20 @@ String getAlbumThumbnailUrl(
|
|||||||
final Album album, {
|
final Album album, {
|
||||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||||
}) {
|
}) {
|
||||||
if (album.albumThumbnailAssetId == null) {
|
if (album.thumbnail.value?.remoteId == null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return _getThumbnailUrl(album.albumThumbnailAssetId!, type: type);
|
return _getThumbnailUrl(album.thumbnail.value!.remoteId!, type: type);
|
||||||
}
|
}
|
||||||
|
|
||||||
String getAlbumThumbNailCacheKey(
|
String getAlbumThumbNailCacheKey(
|
||||||
final Album album, {
|
final Album album, {
|
||||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||||
}) {
|
}) {
|
||||||
if (album.albumThumbnailAssetId == null) {
|
if (album.thumbnail.value?.remoteId == null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type);
|
return _getThumbnailCacheKey(album.thumbnail.value!.remoteId!, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
String getImageUrl(final Asset asset) {
|
String getImageUrl(final Asset asset) {
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
|
// ignore_for_file: deprecated_member_use_from_same_package
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:hive/hive.dart';
|
import 'package:hive/hive.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.dart';
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
|
||||||
import 'package:immich_mobile/shared/models/store.dart';
|
import 'package:immich_mobile/shared/models/store.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
|
||||||
|
|
||||||
Future<void> migrateHiveToStoreIfNecessary() async {
|
Future<void> migrateHiveToStoreIfNecessary() async {
|
||||||
try {
|
try {
|
||||||
@ -22,3 +26,9 @@ _migrateSingleKey(Box box, String hiveKey, StoreKey key) async {
|
|||||||
await box.delete(hiveKey);
|
await box.delete(hiveKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> migrateJsonCacheIfNecessary() async {
|
||||||
|
await AlbumCacheService().invalidate();
|
||||||
|
await SharedAlbumCacheService().invalidate();
|
||||||
|
await AssetCacheService().invalidate();
|
||||||
|
}
|
||||||
|
@ -6,3 +6,13 @@ class Pair<T1, T2> {
|
|||||||
|
|
||||||
const Pair(this.first, this.second);
|
const Pair(this.first, this.second);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An immutable triple or 3-tuple
|
||||||
|
/// TODO replace with Record once Dart 2.19 is available
|
||||||
|
class Triple<T1, T2, T3> {
|
||||||
|
final T1 first;
|
||||||
|
final T2 second;
|
||||||
|
final T3 third;
|
||||||
|
|
||||||
|
const Triple(this.first, this.second, this.third);
|
||||||
|
}
|
||||||
|
BIN
mobile/openapi/doc/UserResponseDto.md
generated
BIN
mobile/openapi/doc/UserResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/user_response_dto.dart
generated
BIN
mobile/openapi/lib/model/user_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/user_response_dto_test.dart
generated
BIN
mobile/openapi/test/user_response_dto_test.dart
generated
Binary file not shown.
@ -13,14 +13,16 @@ void main() {
|
|||||||
|
|
||||||
testAssets.add(
|
testAssets.add(
|
||||||
Asset(
|
Asset(
|
||||||
deviceAssetId: '$i',
|
localId: '$i',
|
||||||
deviceId: '',
|
deviceId: 1,
|
||||||
ownerId: '',
|
ownerId: 1,
|
||||||
fileCreatedAt: date,
|
fileCreatedAt: date,
|
||||||
fileModifiedAt: date,
|
fileModifiedAt: date,
|
||||||
|
updatedAt: date,
|
||||||
durationInSeconds: 0,
|
durationInSeconds: 0,
|
||||||
fileName: '',
|
fileName: '',
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
|
isLocal: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
50
mobile/test/diff_test.dart
Normal file
50
mobile/test/diff_test.dart
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final List<int> listA = [1, 2, 3, 4, 6];
|
||||||
|
final List<int> listB = [1, 3, 5, 7];
|
||||||
|
|
||||||
|
group('Test grouped', () {
|
||||||
|
test('test partial overlap', () async {
|
||||||
|
final List<int> onlyInA = [];
|
||||||
|
final List<int> onlyInB = [];
|
||||||
|
final List<int> inBoth = [];
|
||||||
|
final changes = await diffSortedLists(
|
||||||
|
listA,
|
||||||
|
listB,
|
||||||
|
compare: (int a, int b) => a.compareTo(b),
|
||||||
|
both: (int a, int b) {
|
||||||
|
inBoth.add(b);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onlyFirst: (int a) => onlyInA.add(a),
|
||||||
|
onlySecond: (int b) => onlyInB.add(b),
|
||||||
|
);
|
||||||
|
expect(changes, true);
|
||||||
|
expect(onlyInA, [2, 4, 6]);
|
||||||
|
expect(onlyInB, [5, 7]);
|
||||||
|
expect(inBoth, [1, 3]);
|
||||||
|
});
|
||||||
|
test('test partial overlap sync', () {
|
||||||
|
final List<int> onlyInA = [];
|
||||||
|
final List<int> onlyInB = [];
|
||||||
|
final List<int> inBoth = [];
|
||||||
|
final changes = diffSortedListsSync(
|
||||||
|
listA,
|
||||||
|
listB,
|
||||||
|
compare: (int a, int b) => a.compareTo(b),
|
||||||
|
both: (int a, int b) {
|
||||||
|
inBoth.add(b);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onlyFirst: (int a) => onlyInA.add(a),
|
||||||
|
onlySecond: (int b) => onlyInB.add(b),
|
||||||
|
);
|
||||||
|
expect(changes, true);
|
||||||
|
expect(onlyInA, [2, 4, 6]);
|
||||||
|
expect(onlyInB, [5, 7]);
|
||||||
|
expect(inBoth, [1, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
@ -12,75 +12,81 @@ import 'package:mockito/mockito.dart';
|
|||||||
])
|
])
|
||||||
import 'favorite_provider_test.mocks.dart';
|
import 'favorite_provider_test.mocks.dart';
|
||||||
|
|
||||||
Asset _getTestAsset(String id, bool favorite) {
|
Asset _getTestAsset(int id, bool favorite) {
|
||||||
return Asset(
|
final Asset a = Asset(
|
||||||
remoteId: id,
|
remoteId: id.toString(),
|
||||||
deviceAssetId: '',
|
localId: id.toString(),
|
||||||
deviceId: '',
|
deviceId: 1,
|
||||||
ownerId: '',
|
ownerId: 1,
|
||||||
fileCreatedAt: DateTime.now(),
|
fileCreatedAt: DateTime.now(),
|
||||||
fileModifiedAt: DateTime.now(),
|
fileModifiedAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
isLocal: false,
|
||||||
durationInSeconds: 0,
|
durationInSeconds: 0,
|
||||||
fileName: '',
|
fileName: '',
|
||||||
isFavorite: favorite,
|
isFavorite: favorite,
|
||||||
);
|
);
|
||||||
|
a.id = id;
|
||||||
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group("Test favoriteProvider", () {
|
group("Test favoriteProvider", () {
|
||||||
|
|
||||||
late MockAssetsState assetsState;
|
late MockAssetsState assetsState;
|
||||||
late MockAssetNotifier assetNotifier;
|
late MockAssetNotifier assetNotifier;
|
||||||
late ProviderContainer container;
|
late ProviderContainer container;
|
||||||
late StateNotifierProvider<FavoriteSelectionNotifier, Set<String>> testFavoritesProvider;
|
late StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>
|
||||||
|
testFavoritesProvider;
|
||||||
|
|
||||||
setUp(() {
|
setUp(
|
||||||
assetsState = MockAssetsState();
|
() {
|
||||||
assetNotifier = MockAssetNotifier();
|
assetsState = MockAssetsState();
|
||||||
container = ProviderContainer();
|
assetNotifier = MockAssetNotifier();
|
||||||
|
container = ProviderContainer();
|
||||||
|
|
||||||
testFavoritesProvider =
|
testFavoritesProvider =
|
||||||
StateNotifierProvider<FavoriteSelectionNotifier, Set<String>>((ref) {
|
StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) {
|
||||||
return FavoriteSelectionNotifier(
|
return FavoriteSelectionNotifier(
|
||||||
assetsState,
|
assetsState,
|
||||||
assetNotifier,
|
assetNotifier,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("Empty favorites provider", () {
|
test("Empty favorites provider", () {
|
||||||
when(assetsState.allAssets).thenReturn([]);
|
when(assetsState.allAssets).thenReturn([]);
|
||||||
expect(<String>{}, container.read(testFavoritesProvider));
|
expect(<int>{}, container.read(testFavoritesProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Non-empty favorites provider", () {
|
test("Non-empty favorites provider", () {
|
||||||
when(assetsState.allAssets).thenReturn([
|
when(assetsState.allAssets).thenReturn([
|
||||||
_getTestAsset("001", false),
|
_getTestAsset(1, false),
|
||||||
_getTestAsset("002", true),
|
_getTestAsset(2, true),
|
||||||
_getTestAsset("003", false),
|
_getTestAsset(3, false),
|
||||||
_getTestAsset("004", false),
|
_getTestAsset(4, false),
|
||||||
_getTestAsset("005", true),
|
_getTestAsset(5, true),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(<String>{"002", "005"}, container.read(testFavoritesProvider));
|
expect(<int>{2, 5}, container.read(testFavoritesProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Toggle favorite", () {
|
test("Toggle favorite", () {
|
||||||
when(assetNotifier.toggleFavorite(null, false))
|
when(assetNotifier.toggleFavorite(null, false))
|
||||||
.thenAnswer((_) async => false);
|
.thenAnswer((_) async => false);
|
||||||
|
|
||||||
final testAsset1 = _getTestAsset("001", false);
|
final testAsset1 = _getTestAsset(1, false);
|
||||||
final testAsset2 = _getTestAsset("002", true);
|
final testAsset2 = _getTestAsset(2, true);
|
||||||
|
|
||||||
when(assetsState.allAssets).thenReturn([testAsset1, testAsset2]);
|
when(assetsState.allAssets).thenReturn([testAsset1, testAsset2]);
|
||||||
|
|
||||||
expect(<String>{"002"}, container.read(testFavoritesProvider));
|
expect(<int>{2}, container.read(testFavoritesProvider));
|
||||||
|
|
||||||
container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset2);
|
container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset2);
|
||||||
expect(<String>{}, container.read(testFavoritesProvider));
|
expect(<int>{}, container.read(testFavoritesProvider));
|
||||||
|
|
||||||
container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset1);
|
container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset1);
|
||||||
expect(<String>{"001"}, container.read(testFavoritesProvider));
|
expect(<int>{1}, container.read(testFavoritesProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Add favorites", () {
|
test("Add favorites", () {
|
||||||
@ -89,16 +95,16 @@ void main() {
|
|||||||
|
|
||||||
when(assetsState.allAssets).thenReturn([]);
|
when(assetsState.allAssets).thenReturn([]);
|
||||||
|
|
||||||
expect(<String>{}, container.read(testFavoritesProvider));
|
expect(<int>{}, container.read(testFavoritesProvider));
|
||||||
|
|
||||||
container.read(testFavoritesProvider.notifier).addToFavorites(
|
container.read(testFavoritesProvider.notifier).addToFavorites(
|
||||||
[
|
[
|
||||||
_getTestAsset("001", false),
|
_getTestAsset(1, false),
|
||||||
_getTestAsset("002", false),
|
_getTestAsset(2, false),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(<String>{"001", "002"}, container.read(testFavoritesProvider));
|
expect(<int>{1, 2}, container.read(testFavoritesProvider));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -187,7 +187,7 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier {
|
|||||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||||
) as _i5.Future<void>);
|
) as _i5.Future<void>);
|
||||||
@override
|
@override
|
||||||
void onNewAssetUploaded(_i4.Asset? newAsset) => super.noSuchMethod(
|
Future<void> onNewAssetUploaded(_i4.Asset? newAsset) => super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#onNewAssetUploaded,
|
#onNewAssetUploaded,
|
||||||
[newAsset],
|
[newAsset],
|
||||||
@ -195,7 +195,7 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier {
|
|||||||
returnValueForMissingStub: null,
|
returnValueForMissingStub: null,
|
||||||
);
|
);
|
||||||
@override
|
@override
|
||||||
dynamic deleteAssets(Set<_i4.Asset>? deleteAssets) => super.noSuchMethod(
|
Future<void> deleteAssets(Set<_i4.Asset> deleteAssets) => super.noSuchMethod(
|
||||||
Invocation.method(
|
Invocation.method(
|
||||||
#deleteAssets,
|
#deleteAssets,
|
||||||
[deleteAssets],
|
[deleteAssets],
|
||||||
|
@ -101,6 +101,7 @@ describe('User', () => {
|
|||||||
shouldChangePassword: true,
|
shouldChangePassword: true,
|
||||||
profileImagePath: '',
|
profileImagePath: '',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
updatedAt: expect.anything(),
|
||||||
oauthId: '',
|
oauthId: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -113,6 +114,7 @@ describe('User', () => {
|
|||||||
shouldChangePassword: true,
|
shouldChangePassword: true,
|
||||||
profileImagePath: '',
|
profileImagePath: '',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
updatedAt: expect.anything(),
|
||||||
oauthId: '',
|
oauthId: '',
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
@ -3583,6 +3583,9 @@
|
|||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"oauthId": {
|
"oauthId": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ export class UserResponseDto {
|
|||||||
shouldChangePassword!: boolean;
|
shouldChangePassword!: boolean;
|
||||||
isAdmin!: boolean;
|
isAdmin!: boolean;
|
||||||
deletedAt?: Date;
|
deletedAt?: Date;
|
||||||
|
updatedAt?: string;
|
||||||
oauthId!: string;
|
oauthId!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
|
|||||||
shouldChangePassword: entity.shouldChangePassword,
|
shouldChangePassword: entity.shouldChangePassword,
|
||||||
isAdmin: entity.isAdmin,
|
isAdmin: entity.isAdmin,
|
||||||
deletedAt: entity.deletedAt,
|
deletedAt: entity.deletedAt,
|
||||||
|
updatedAt: entity.updatedAt,
|
||||||
oauthId: entity.oauthId,
|
oauthId: entity.oauthId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -100,6 +100,7 @@ const adminUserResponse = Object.freeze({
|
|||||||
shouldChangePassword: false,
|
shouldChangePassword: false,
|
||||||
profileImagePath: '',
|
profileImagePath: '',
|
||||||
createdAt: '2021-01-01',
|
createdAt: '2021-01-01',
|
||||||
|
updatedAt: '2021-01-01',
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(UserService.name, () => {
|
describe(UserService.name, () => {
|
||||||
@ -162,6 +163,7 @@ describe(UserService.name, () => {
|
|||||||
shouldChangePassword: false,
|
shouldChangePassword: false,
|
||||||
profileImagePath: '',
|
profileImagePath: '',
|
||||||
createdAt: '2021-01-01',
|
createdAt: '2021-01-01',
|
||||||
|
updatedAt: '2021-01-01',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
6
web/src/api/open-api/api.ts
generated
6
web/src/api/open-api/api.ts
generated
@ -2406,6 +2406,12 @@ export interface UserResponseDto {
|
|||||||
* @memberof UserResponseDto
|
* @memberof UserResponseDto
|
||||||
*/
|
*/
|
||||||
'deletedAt'?: string;
|
'deletedAt'?: string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof UserResponseDto
|
||||||
|
*/
|
||||||
|
'updatedAt'?: string;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {string}
|
||||||
|
Loading…
Reference in New Issue
Block a user