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:
Fynn Petersen-Frey 2023-03-03 23:38:30 +01:00 committed by GitHub
parent 8f11529a75
commit 8708867c1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 9024 additions and 893 deletions

View file

@ -142,6 +142,7 @@
"library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title",
"library_page_device_albums": "Albums on Device",
"login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port/api",

View file

@ -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/routing/router.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/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/asset.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
@ -42,6 +46,7 @@ void main() async {
await initApp();
final db = await loadDb();
await migrateHiveToStoreIfNecessary();
await migrateJsonCacheIfNecessary();
runApp(getMainWidget(db));
}
@ -93,7 +98,13 @@ Future<void> initApp() async {
Future<Isar> loadDb() async {
final dir = await getApplicationDocumentsDirectory();
Isar db = await Isar.open(
[StoreValueSchema],
[
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
],
directory: dir.path,
maxSizeMiB: 256,
);

View file

@ -1,37 +1,43 @@
import 'package:collection/collection.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_cache.service.dart';
import 'package:immich_mobile/shared/models/asset.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>> {
AlbumNotifier(this._albumService, this._albumCacheService) : super([]);
AlbumNotifier(this._albumService, this._db) : super([]);
final AlbumService _albumService;
final AlbumCacheService _albumCacheService;
void _cacheState() {
_albumCacheService.put(state);
}
final Isar _db;
Future<void> getAllAlbums() async {
if (await _albumCacheService.isValid() && state.isEmpty) {
final albums = await _albumCacheService.get();
if (albums != null) {
state = albums;
}
}
final albums = await _albumService.getAlbums(isShared: false);
if (albums != null) {
final User me = Store.get(StoreKey.currentUser);
List<Album> albums = await _db.albums
.filter()
.owner((q) => q.isarIdEqualTo(me.isarId))
.findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums;
}
await Future.wait([
_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;
_cacheState();
}
}
void deleteAlbum(Album album) {
Future<bool> deleteAlbum(Album album) async {
state = state.where((a) => a.id != album.id).toList();
_cacheState();
return _albumService.deleteAlbum(album);
}
Future<Album?> createAlbum(
@ -39,20 +45,16 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
Set<Asset> assets,
) async {
Album? album = await _albumService.createAlbum(albumTitle, assets, []);
if (album != null) {
state = [...state, album];
_cacheState();
return album;
}
return null;
return album;
}
}
final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) {
return AlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(albumCacheServiceProvider),
ref.watch(dbProvider),
);
});

View file

@ -58,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
);
}
void addNewAssets(List<Asset> assets) {
void addNewAssets(Iterable<Asset> assets) {
state = state.copyWith(
selectedNewAssetsForAlbum: {
...state.selectedNewAssetsForAlbum,

View file

@ -1,21 +1,18 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.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_cache.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.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>> {
SharedAlbumNotifier(this._albumService, this._sharedAlbumCacheService)
: super([]);
SharedAlbumNotifier(this._albumService, this._db) : super([]);
final AlbumService _albumService;
final SharedAlbumCacheService _sharedAlbumCacheService;
void _cacheState() {
_sharedAlbumCacheService.put(state);
}
final Isar _db;
Future<Album?> createSharedAlbum(
String albumName,
@ -23,7 +20,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
Iterable<User> sharedUsers,
) async {
try {
var newAlbum = await _albumService.createAlbum(
final Album? newAlbum = await _albumService.createAlbum(
albumName,
assets,
sharedUsers,
@ -31,61 +28,44 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
if (newAlbum != null) {
state = [...state, newAlbum];
_cacheState();
return newAlbum;
}
return newAlbum;
} catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}");
return null;
}
return null;
}
Future<void> getAllSharedAlbums() async {
if (await _sharedAlbumCacheService.isValid() && state.isEmpty) {
final albums = await _sharedAlbumCacheService.get();
if (albums != null) {
state = albums;
}
var albums = await _db.albums.filter().sharedEqualTo(true).findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums;
}
List<Album>? sharedAlbums = await _albumService.getAlbums(isShared: true);
if (sharedAlbums != null) {
state = sharedAlbums;
_cacheState();
await _albumService.refreshRemoteAlbums(isShared: true);
albums = await _db.albums.filter().sharedEqualTo(true).findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums;
}
}
void deleteAlbum(Album album) {
Future<bool> deleteAlbum(Album album) {
state = state.where((a) => a.id != album.id).toList();
_cacheState();
return _albumService.deleteAlbum(album);
}
Future<bool> leaveAlbum(Album album) async {
var res = await _albumService.leaveAlbum(album);
if (res) {
state = state.where((a) => a.id != album.id).toList();
_cacheState();
await deleteAlbum(album);
return true;
} else {
return false;
}
}
Future<bool> removeAssetFromAlbum(
Album album,
Iterable<Asset> assets,
) async {
var res = await _albumService.removeAssetFromAlbum(album, assets);
if (res) {
return true;
} else {
return false;
}
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
return _albumService.removeAssetFromAlbum(album, assets);
}
}
@ -93,13 +73,15 @@ final sharedAlbumProvider =
StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) {
return SharedAlbumNotifier(
ref.watch(albumServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
ref.watch(dbProvider),
);
});
final sharedAlbumDetailProvider =
FutureProvider.autoDispose.family<Album?, String>((ref, albumId) async {
FutureProvider.autoDispose.family<Album?, int>((ref, albumId) async {
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
return await sharedAlbumService.getAlbumDetail(albumId);
final Album? a = await sharedAlbumService.getAlbumDetail(albumId);
await a?.loadSortedAssets();
return a;
});

View file

@ -3,8 +3,8 @@ import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
final suggestedSharedUsersProvider =
FutureProvider.autoDispose<List<User>>((ref) async {
FutureProvider.autoDispose<List<User>>((ref) {
UserService userService = ref.watch(userServiceProvider);
return await userService.getAllUsers(isAll: false) ?? [];
return userService.getUsersInDb();
});

View file

@ -1,34 +1,129 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.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/asset.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/db.provider.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:photo_manager/photo_manager.dart';
final albumServiceProvider = Provider(
(ref) => AlbumService(
ref.watch(apiServiceProvider),
ref.watch(userServiceProvider),
ref.watch(backgroundServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
),
);
class AlbumService {
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 {
try {
final dto = await _apiService.albumApi
.getAllAlbums(shared: isShared ? isShared : null);
return dto?.map(Album.remote).toList();
} catch (e) {
debugPrint("Error getAllSharedAlbum ${e.toString()}");
return null;
/// Checks all selected device albums for changes of albums and their assets
/// Updates the local database and returns `true` if there were any changes
Future<bool> refreshDeviceAlbums() async {
if (!_localCompleter.isCompleted) {
// guard against concurrent calls
return _localCompleter.future;
}
_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(
@ -37,56 +132,51 @@ class AlbumService {
Iterable<User> sharedUsers = const [],
]) async {
try {
final dto = await _apiService.albumApi.createAlbum(
AlbumResponseDto? remote = await _apiService.albumApi.createAlbum(
CreateAlbumDto(
albumName: albumName,
assetIds: assets.map((asset) => asset.remoteId!).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) {
debugPrint("Error createSharedAlbum ${e.toString()}");
return null;
}
return null;
}
/*
* Creates names like Untitled, Untitled (1), Untitled (2), ...
*/
String _getNextAlbumName(List<Album>? albums) {
Future<String> _getNextAlbumName() async {
const baseName = "Untitled";
for (int round = 0;; round++) {
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
if (albums != null) {
for (int round = 0; round < albums.length; round++) {
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
if (albums.where((a) => a.name == proposedName).isEmpty) {
return proposedName;
}
if (null ==
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
return proposedName;
}
}
return baseName;
}
Future<Album?> createAlbumWithGeneratedName(
Iterable<Asset> assets,
) async {
return createAlbum(
_getNextAlbumName(await getAlbums(isShared: false)),
await _getNextAlbumName(),
assets,
[],
);
}
Future<Album?> getAlbumDetail(String albumId) async {
try {
final dto = await _apiService.albumApi.getAlbumInfo(albumId);
return dto != null ? Album.remote(dto) : null;
} catch (e) {
debugPrint('Error [getAlbumDetail] ${e.toString()}');
return null;
}
Future<Album?> getAlbumDetail(int albumId) {
return _db.albums.get(albumId);
}
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
@ -98,6 +188,10 @@ class AlbumService {
album.remoteId!,
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;
} catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
@ -110,26 +204,53 @@ class AlbumService {
Album album,
) async {
try {
var result = await _apiService.albumApi.addUsersToAlbum(
final result = await _apiService.albumApi.addUsersToAlbum(
album.remoteId!,
AddUsersDto(sharedUserIds: sharedUserIds),
);
return result != null;
if (result != null) {
album.sharedUsers
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
await _db.writeTxn(() => album.sharedUsers.save());
return true;
}
} catch (e) {
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
return false;
}
return false;
}
Future<bool> deleteAlbum(Album album) async {
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;
} catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}");
return false;
}
return false;
}
Future<bool> leaveAlbum(Album album) async {
@ -153,6 +274,8 @@ class AlbumService {
assetIds: assets.map((e) => e.remoteId!).toList(growable: false),
),
);
album.assets.removeAll(assets);
await _db.writeTxn(() => album.assets.update(unlink: assets));
return true;
} catch (e) {
@ -173,6 +296,7 @@ class AlbumService {
),
);
album.name = newAlbumTitle;
await _db.writeTxn(() => _db.albums.put(album));
return true;
} catch (e) {

View file

@ -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/services/json_cache.dart';
class BaseAlbumCacheService extends JsonCache<List<Album>> {
BaseAlbumCacheService(super.cacheFileName);
@Deprecated("only kept to remove its files after migration")
class _BaseAlbumCacheService extends JsonCache<List<Album>> {
_BaseAlbumCacheService(super.cacheFileName);
@override
void put(List<Album> data) {
putRawData(data.map((e) => e.toJson()).toList());
}
void put(List<Album> data) {}
@override
Future<List<Album>?> get() async {
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;
}
}
Future<List<Album>?> get() => Future.value(null);
}
class AlbumCacheService extends BaseAlbumCacheService {
@Deprecated("only kept to remove its files after migration")
class AlbumCacheService extends _BaseAlbumCacheService {
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");
}
final albumCacheServiceProvider = Provider(
(ref) => AlbumCacheService(),
);
final sharedAlbumCacheServiceProvider = Provider(
(ref) => SharedAlbumCacheService(),
);

View file

@ -25,7 +25,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
@override
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 sharedAlbums = ref.watch(sharedAlbumProvider);

View file

@ -1,11 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.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/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class AlbumThumbnailCard extends StatelessWidget {
final Function()? onTap;
@ -20,7 +16,6 @@ class AlbumThumbnailCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
return LayoutBuilder(
builder: (context, constraints) {
@ -42,21 +37,11 @@ class AlbumThumbnailCard extends StatelessWidget {
);
}
buildAlbumThumbnail() {
return CachedNetworkImage(
width: 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),
);
}
buildAlbumThumbnail() => ImmichImage(
album.thumbnail.value,
width: cardSize,
height: cardSize,
);
return GestureDetector(
onTap: onTap,
@ -72,7 +57,7 @@ class AlbumThumbnailCard extends StatelessWidget {
height: cardSize,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: album.albumThumbnailAssetId == null
child: album.thumbnail.value == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),

View file

@ -68,7 +68,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.albumThumbnailAssetId == null
child: album.thumbnail.value == null
? buildEmptyThumbnail()
: buildAlbumThumbnail(),
),

View file

@ -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/asset_selection.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/shared/models/album.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
@ -35,19 +34,18 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
void onDeleteAlbumPressed() async {
ImmichLoadingOverlayController.appLoader.show();
bool isSuccess = await ref.watch(albumServiceProvider).deleteAlbum(album);
if (isSuccess) {
if (album.shared) {
ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [SharingRoute()]));
} else {
ref.watch(albumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
}
final bool success;
if (album.shared) {
success =
await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [SharingRoute()]));
} else {
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
}
if (!success) {
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_delete".tr(),
@ -208,11 +206,12 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
: null,
centerTitle: false,
actions: [
IconButton(
splashRadius: 25,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
if (album.isRemote)
IconButton(
splashRadius: 25,
onPressed: buildBottomSheet,
icon: const Icon(Icons.more_horiz_rounded),
),
],
);
}

View file

@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@ -22,7 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var deviceId = ref.watch(authenticationProvider).deviceId;
final selectedAssetsInAlbumViewer =
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final isMultiSelectionEnable =
@ -88,7 +86,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
bottom: 5,
child: Icon(
asset.isRemote
? (deviceId == asset.deviceId
? (asset.isLocal
? Icons.cloud_done_outlined
: Icons.cloud_outlined)
: Icons.cloud_off_outlined,

View file

@ -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';
class AlbumViewerPage extends HookConsumerWidget {
final String albumId;
final int albumId;
const AlbumViewerPage({Key? key, required this.albumId}) : super(key: key);
@ -101,7 +101,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildTitle(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
child: userId == album.ownerId
child: userId == album.ownerId && album.isRemote
? AlbumViewerEditableTitle(
album: album,
titleFocusNode: titleFocusNode,
@ -122,9 +122,10 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildAlbumDateRange(Album album) {
final DateTime startDate = album.assets.first.fileCreatedAt;
final DateTime endDate = album.assets.last.fileCreatedAt; //Need default.
final String startDateText =
(startDate.year == endDate.year ? DateFormat.MMMd() : DateFormat.yMMMd())
.format(startDate);
final String startDateText = (startDate.year == endDate.year
? DateFormat.MMMd()
: DateFormat.yMMMd())
.format(startDate);
final String endDateText = DateFormat.yMMMd().format(endDate);
return Padding(
@ -188,7 +189,7 @@ class AlbumViewerPage extends HookConsumerWidget {
final bool showStorageIndicator =
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
if (album.assets.isNotEmpty) {
if (album.sortedAssets.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid(
@ -201,8 +202,8 @@ class AlbumViewerPage extends HookConsumerWidget {
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return AlbumViewerThumbnail(
asset: album.assets[index],
assetList: album.assets,
asset: album.sortedAssets[index],
assetList: album.sortedAssets,
showStorageIndicator: showStorageIndicator,
);
},
@ -267,17 +268,18 @@ class AlbumViewerPage extends HookConsumerWidget {
controller: scrollController,
slivers: [
buildHeader(album),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: buildControlButton(album),
if (album.isRemote)
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: buildControlButton(album),
),
),
),
),
SliverSafeArea(
sliver: buildImageGrid(album),
),

View file

@ -44,9 +44,13 @@ class LibraryPage extends HookConsumerWidget {
List<Album> sortedAlbums() {
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() {
@ -194,6 +198,8 @@ class LibraryPage extends HookConsumerWidget {
final sorted = sortedAlbums();
final local = albums.where((a) => a.isLocal).toList();
return Scaffold(
appBar: buildAppBar(),
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,
),
),
),
),
),
),
],
),
);

View file

@ -1,23 +1,19 @@
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:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.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/ui/sharing_sliver_appbar.dart';
import 'package:immich_mobile/routing/router.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 {
const SharingPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
useEffect(
@ -39,16 +35,10 @@ class SharingPage extends HookConsumerWidget {
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
child: ImmichImage(
album.thumbnail.value,
width: 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(

View file

@ -14,10 +14,14 @@ class ExifBottomSheet extends HookConsumerWidget {
const ExifBottomSheet({Key? key, required this.assetDetail})
: super(key: key);
bool get showMap => assetDetail.latitude != null && assetDetail.longitude != null;
bool get showMap =>
assetDetail.exifInfo?.latitude != null &&
assetDetail.exifInfo?.longitude != null;
@override
Widget build(BuildContext context, WidgetRef ref) {
final ExifInfo? exifInfo = assetDetail.exifInfo;
buildMap() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
@ -33,8 +37,8 @@ class ExifBottomSheet extends HookConsumerWidget {
options: MapOptions(
interactiveFlags: InteractiveFlag.none,
center: LatLng(
assetDetail.latitude ?? 0,
assetDetail.longitude ?? 0,
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
zoom: 16.0,
),
@ -55,8 +59,8 @@ class ExifBottomSheet extends HookConsumerWidget {
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(
assetDetail.latitude ?? 0,
assetDetail.longitude ?? 0,
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
@ -74,8 +78,6 @@ class ExifBottomSheet extends HookConsumerWidget {
final textColor = Theme.of(context).primaryColor;
ExifInfo? exifInfo = assetDetail.exifInfo;
buildLocationText() {
return Text(
"${exifInfo?.city}, ${exifInfo?.state}",
@ -134,7 +136,7 @@ class ExifBottomSheet extends HookConsumerWidget {
exifInfo.state != null)
buildLocationText(),
Text(
"${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}",
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
style: const TextStyle(fontSize: 12),
)
],

View file

@ -75,15 +75,11 @@ class GalleryViewerPage extends HookConsumerWidget {
ref.watch(favoriteProvider.notifier).toggleFavorite(asset);
}
getAssetExif() async {
if (assetList[indexOfAsset.value].isRemote) {
assetDetail = await ref
.watch(assetServiceProvider)
.getAssetById(assetList[indexOfAsset.value].id);
} else {
// TODO local exif parsing?
assetDetail = assetList[indexOfAsset.value];
}
void getAssetExif() async {
assetDetail = assetList[indexOfAsset.value];
assetDetail = await ref
.watch(assetServiceProvider)
.loadExif(assetList[indexOfAsset.value]);
}
/// Thumbnail image of a remote asset. Required asset.isRemote

View file

@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.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({}) {
state = assetsState.allAssets
.where((asset) => asset.isFavorite)
@ -13,7 +13,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
final AssetsState assetsState;
final AssetNotifier assetNotifier;
void _setFavoriteForAssetId(String id, bool favorite) {
void _setFavoriteForAssetId(int id, bool favorite) {
if (!favorite) {
state = state.difference({id});
} else {
@ -21,7 +21,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
}
}
bool _isFavorite(String id) {
bool _isFavorite(int id) {
return state.contains(id);
}
@ -38,22 +38,22 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
Future<void> addToFavorites(Iterable<Asset> assets) {
state = state.union(assets.map((a) => a.id).toSet());
final futures = assets.map((a) =>
assetNotifier.toggleFavorite(
a,
true,
),
);
final futures = assets.map(
(a) => assetNotifier.toggleFavorite(
a,
true,
),
);
return Future.wait(futures);
}
}
final favoriteProvider =
StateNotifierProvider<FavoriteSelectionNotifier, Set<String>>((ref) {
StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) {
return FavoriteSelectionNotifier(
ref.watch(assetProvider),
ref.watch(assetProvider.notifier),
ref.watch(assetProvider),
ref.watch(assetProvider.notifier),
);
});

View file

@ -23,7 +23,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
ItemPositionsListener.create();
bool _scrolling = false;
final Set<String> _selectedAssets = HashSet();
final Set<int> _selectedAssets = HashSet();
Set<Asset> _getSelectedAssets() {
return _selectedAssets

View file

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
@ -32,8 +31,6 @@ class ThumbnailImage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var deviceId = ref.watch(authenticationProvider).deviceId;
Widget buildSelectionIcon(Asset asset) {
if (isSelected) {
return Icon(
@ -103,7 +100,7 @@ class ThumbnailImage extends HookConsumerWidget {
bottom: 5,
child: Icon(
asset.isRemote
? (deviceId == asset.deviceId
? (asset.isLocal
? Icons.cloud_done_outlined
: Icons.cloud_outlined)
: Icons.cloud_off_outlined,

View file

@ -38,7 +38,7 @@ class HomePage extends HookConsumerWidget {
final selectionEnabledHook = useState(false);
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 albumService = ref.watch(albumServiceProvider);

View file

@ -3,15 +3,15 @@ import 'package:flutter/services.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/services/asset_cache.service.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/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/services/api.service.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:openapi/api.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
@ -19,9 +19,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
this._deviceInfoService,
this._backupService,
this._apiService,
this._assetCacheService,
this._albumCacheService,
this._sharedAlbumCacheService,
) : super(
AuthenticationState(
deviceId: "",
@ -48,9 +45,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
final DeviceInfoService _deviceInfoService;
final BackupService _backupService;
final ApiService _apiService;
final AssetCacheService _assetCacheService;
final AlbumCacheService _albumCacheService;
final SharedAlbumCacheService _sharedAlbumCacheService;
Future<bool> login(
String email,
@ -98,9 +92,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Hive.box(userInfoBox).delete(accessTokenKey),
Store.delete(StoreKey.assetETag),
Store.delete(StoreKey.userRemoteId),
_assetCacheService.invalidate(),
_albumCacheService.invalidate(),
_sharedAlbumCacheService.invalidate(),
Store.delete(StoreKey.currentUser),
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey)
]);
@ -160,7 +152,10 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
var deviceInfo = await _deviceInfoService.getDeviceInfo();
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
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.currentUser, User.fromDto(userResponseDto));
state = state.copyWith(
isAuthenticated: true,
@ -218,8 +213,5 @@ final authenticationProvider =
ref.watch(deviceInfoServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(apiServiceProvider),
ref.watch(assetCacheServiceProvider),
ref.watch(albumCacheServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
);
});

View file

@ -1,8 +1,5 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
class SearchResultPageState {
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
String toString() {
return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)';

View file

@ -2,19 +2,23 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.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:isar/isar.dart';
import 'package:openapi/api.dart';
final searchServiceProvider = Provider(
(ref) => SearchService(
ref.watch(apiServiceProvider),
ref.watch(dbProvider),
),
);
class SearchService {
final ApiService _apiService;
final Isar _db;
SearchService(this._apiService);
SearchService(this._apiService, this._db);
Future<List<String>?> getUserSuggestedSearchTerms() async {
try {
@ -26,13 +30,15 @@ class SearchService {
}
Future<List<Asset>?> searchAsset(String searchTerm) async {
// TODO search in local DB: 1. when offline, 2. to find local assets
try {
final List<AssetResponseDto>? results = await _apiService.assetApi
.searchAsset(SearchAssetDto(searchTerm: searchTerm));
if (results == 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) {
debugPrint("[ERROR] [searchAsset] ${e.toString()}");
return null;

View file

@ -698,7 +698,7 @@ class SelectUserForSharingRoute extends PageRouteInfo<void> {
class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
AlbumViewerRoute({
Key? key,
required String albumId,
required int albumId,
}) : super(
AlbumViewerRoute.name,
path: '/album-viewer-page',
@ -719,7 +719,7 @@ class AlbumViewerRouteArgs {
final Key? key;
final String albumId;
final int albumId;
@override
String toString() {

View file

@ -1,132 +1,153 @@
import 'package:flutter/cupertino.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:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
part 'album.g.dart';
@Collection(inheritance: false)
class Album {
Album.remote(AlbumResponseDto dto)
: 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();
@protected
Album({
this.remoteId,
this.localId,
required this.name,
required this.ownerId,
required this.createdAt,
required this.modifiedAt,
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;
@Index(unique: false, replace: false, type: IndexType.hash)
String? localId;
String name;
String ownerId;
DateTime createdAt;
DateTime modifiedAt;
bool shared;
String? albumThumbnailAssetId;
int assetCount;
List<User> sharedUsers = const [];
List<Asset> assets = const [];
final IsarLink<User> owner = IsarLink<User>();
final IsarLink<Asset> thumbnail = IsarLink<Asset>();
final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>();
List<Asset> _sortedAssets = [];
@ignore
List<Asset> get sortedAssets => _sortedAssets;
@ignore
bool get isRemote => remoteId != null;
@ignore
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
bool operator ==(other) {
if (other is! Album) return false;
return remoteId == other.remoteId &&
return id == other.id &&
remoteId == other.remoteId &&
localId == other.localId &&
name == other.name &&
createdAt == other.createdAt &&
modifiedAt == other.modifiedAt &&
shared == other.shared &&
ownerId == other.ownerId &&
albumThumbnailAssetId == other.albumThumbnailAssetId;
owner.value == other.owner.value &&
thumbnail.value == other.thumbnail.value &&
sharedUsers.length == other.sharedUsers.length &&
assets.length == other.assets.length;
}
@override
@ignore
int get hashCode =>
id.hashCode ^
remoteId.hashCode ^
localId.hashCode ^
name.hashCode ^
createdAt.hashCode ^
modifiedAt.hashCode ^
shared.hashCode ^
ownerId.hashCode ^
albumThumbnailAssetId.hashCode;
owner.value.hashCode ^
thumbnail.value.hashCode ^
sharedUsers.length.hashCode ^
assets.length.hashCode;
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json["remoteId"] = remoteId;
json["localId"] = localId;
json["name"] = name;
json["ownerId"] = ownerId;
json["createdAt"] = createdAt.millisecondsSinceEpoch;
json["modifiedAt"] = modifiedAt.millisecondsSinceEpoch;
json["shared"] = shared;
json["albumThumbnailAssetId"] = albumThumbnailAssetId;
json["assetCount"] = assetCount;
json["sharedUsers"] = sharedUsers;
json["assets"] = assets;
return json;
static Album local(AssetPathEntity ape) {
final Album a = Album(
name: ape.name,
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
);
a.owner.value = Store.get(StoreKey.currentUser);
a.localId = ape.id;
return a;
}
static Album? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return Album(
remoteId: json["remoteId"],
localId: json["localId"],
name: json["name"],
ownerId: json["ownerId"],
createdAt: DateTime.fromMillisecondsSinceEpoch(
json["createdAt"],
isUtc: true,
),
modifiedAt: DateTime.fromMillisecondsSinceEpoch(
json["modifiedAt"],
isUtc: true,
),
shared: json["shared"],
albumThumbnailAssetId: json["albumThumbnailAssetId"],
assetCount: json["assetCount"],
sharedUsers: _listFromJson<User>(json["sharedUsers"], User.fromJson),
assets: _listFromJson<Asset>(json["assets"], Asset.fromJson),
);
static Future<Album> remote(AlbumResponseDto dto) async {
final Isar db = Isar.getInstance()!;
final Album a = Album(
remoteId: dto.id,
name: dto.albumName,
createdAt: DateTime.parse(dto.createdAt),
modifiedAt: DateTime.parse(dto.updatedAt),
shared: dto.shared,
);
a.owner.value = await db.users.getById(dto.ownerId);
if (dto.albumThumbnailAssetId != null) {
a.thumbnail.value = await db.assets
.where()
.remoteIdEqualTo(dto.albumThumbnailAssetId)
.findFirst();
}
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>(
dynamic json,
T? Function(dynamic) fromJson,
) {
final result = <T>[];
if (json is List && json.isNotEmpty) {
for (final entry in json) {
final value = fromJson(entry);
if (value != null) {
result.add(value);
}
}
extension AssetsHelper on IsarCollection<Album> {
Future<void> store(Album a) async {
await put(a);
await a.owner.save();
await a.thumbnail.save();
await a.sharedUsers.save();
await a.assets.save();
}
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();
}

File diff suppressed because it is too large Load diff

View file

@ -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/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:photo_manager/photo_manager.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart';
import 'package:path/path.dart' as p;
part 'asset.g.dart';
/// Asset (online or local)
@Collection(inheritance: false)
class Asset {
Asset.remote(AssetResponseDto remote)
: remoteId = remote.id,
fileCreatedAt = DateTime.parse(remote.fileCreatedAt),
fileModifiedAt = DateTime.parse(remote.fileModifiedAt),
isLocal = false,
fileCreatedAt = DateTime.parse(remote.fileCreatedAt).toUtc(),
fileModifiedAt = DateTime.parse(remote.fileModifiedAt).toUtc(),
updatedAt = DateTime.parse(remote.updatedAt).toUtc(),
durationInSeconds = remote.duration.toDuration().inSeconds,
fileName = p.basename(remote.originalPath),
height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(),
livePhotoVideoId = remote.livePhotoVideoId,
deviceAssetId = remote.deviceAssetId,
deviceId = remote.deviceId,
ownerId = remote.ownerId,
latitude = remote.exifInfo?.latitude?.toDouble(),
longitude = remote.exifInfo?.longitude?.toDouble(),
localId = remote.deviceAssetId,
deviceId = fastHash(remote.deviceId),
ownerId = fastHash(remote.ownerId),
exifInfo =
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite;
Asset.local(AssetEntity local, String owner)
Asset.local(AssetEntity local)
: localId = local.id,
latitude = local.latitude,
longitude = local.longitude,
isLocal = true,
durationInSeconds = local.duration,
height = local.height,
width = local.width,
fileName = local.title!,
deviceAssetId = local.id,
deviceId = Hive.box(userInfoBox).get(deviceIdKey),
ownerId = owner,
deviceId = Store.get(StoreKey.deviceIdHash),
ownerId = Store.get<User>(StoreKey.currentUser)!.isarId,
fileModifiedAt = local.modifiedDateTime.toUtc(),
updatedAt = local.modifiedDateTime.toUtc(),
isFavorite = local.isFavorite,
fileCreatedAt = local.createDateTime.toUtc() {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
}
if (local.latitude != null) {
exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
}
}
Asset({
this.localId,
this.remoteId,
required this.deviceAssetId,
required this.localId,
required this.deviceId,
required this.ownerId,
required this.fileCreatedAt,
required this.fileModifiedAt,
this.latitude,
this.longitude,
required this.updatedAt,
required this.durationInSeconds,
this.width,
this.height,
@ -62,21 +67,22 @@ class Asset {
this.livePhotoVideoId,
this.exifInfo,
required this.isFavorite,
required this.isLocal,
});
@ignore
AssetEntity? _local;
@ignore
AssetEntity? get local {
if (isLocal && _local == null) {
_local = AssetEntity(
id: localId!.toString(),
id: localId.toString(),
typeInt: isImage ? 1 : 2,
width: width!,
height: height!,
duration: durationInSeconds,
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
latitude: latitude,
longitude: longitude,
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
title: fileName,
);
@ -84,110 +90,136 @@ class Asset {
return _local;
}
String? localId;
Id id = Isar.autoIncrement;
@Index(unique: false, replace: false, type: IndexType.hash)
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 fileModifiedAt;
double? latitude;
double? longitude;
DateTime updatedAt;
int durationInSeconds;
int? width;
short? width;
int? height;
short? height;
String fileName;
String? livePhotoVideoId;
ExifInfo? exifInfo;
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);
@ignore
bool get isRemote => remoteId != null;
bool get isLocal => localId != null;
@ignore
bool get isImage => durationInSeconds == 0;
@ignore
Duration get duration => Duration(seconds: durationInSeconds);
@override
bool operator ==(other) {
if (other is! Asset) return false;
return id == other.id && isLocal == other.isLocal;
return id == other.id;
}
@override
@ignore
int get hashCode => id.hashCode;
// methods below are only required for caching as JSON
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json["localId"] = localId;
json["remoteId"] = remoteId;
json["deviceAssetId"] = deviceAssetId;
json["deviceId"] = deviceId;
json["ownerId"] = ownerId;
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();
bool updateFromAssetEntity(AssetEntity ae) {
// TODO check more fields;
// width and height are most important because local assets require these
final bool hasChanges =
isLocal == false || width != ae.width || height != ae.height;
if (hasChanges) {
isLocal = true;
width = ae.width;
height = ae.height;
}
return json;
return hasChanges;
}
static Asset? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return Asset(
localId: json["localId"],
remoteId: json["remoteId"],
deviceAssetId: json["deviceAssetId"],
deviceId: json["deviceId"],
ownerId: json["ownerId"],
fileCreatedAt:
DateTime.fromMillisecondsSinceEpoch(json["fileCreatedAt"], isUtc: true),
fileModifiedAt: DateTime.fromMillisecondsSinceEpoch(
json["fileModifiedAt"],
isUtc: true,
),
latitude: json["latitude"],
longitude: json["longitude"],
durationInSeconds: json["durationInSeconds"],
width: json["width"],
height: json["height"],
fileName: json["fileName"],
livePhotoVideoId: json["livePhotoVideoId"],
exifInfo: ExifInfo.fromJson(json["exifInfo"]),
isFavorite: json["isFavorite"],
);
Asset withUpdatesFromDto(AssetResponseDto dto) =>
Asset.remote(dto).updateFromDb(this);
Asset updateFromDb(Asset a) {
assert(localId == a.localId);
assert(deviceId == a.deviceId);
id = a.id;
isLocal |= a.isLocal;
remoteId ??= a.remoteId;
width ??= a.width;
height ??= a.height;
exifInfo ??= a.exifInfo;
exifInfo?.id = id;
return this;
}
Future<void> put(Isar db) async {
await db.assets.put(this);
if (exifInfo != null) {
exifInfo!.id = id;
await db.exifInfos.put(exifInfo!);
}
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)),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,86 +1,93 @@
import 'package:isar/isar.dart';
import 'package:openapi/api.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 {
Id? id;
int? fileSize;
String? make;
String? model;
String? orientation;
String? lensModel;
double? fNumber;
double? focalLength;
int? iso;
double? exposureTime;
String? lens;
float? f;
float? mm;
short? iso;
float? exposureSeconds;
float? lat;
float? long;
String? city;
String? state;
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)
: fileSize = dto.fileSizeInByte,
make = dto.make,
model = dto.model,
orientation = dto.orientation,
lensModel = dto.lensModel,
fNumber = dto.fNumber?.toDouble(),
focalLength = dto.focalLength?.toDouble(),
lens = dto.lensModel,
f = dto.fNumber?.toDouble(),
mm = dto.focalLength?.toDouble(),
iso = dto.iso?.toInt(),
exposureTime = dto.exposureTime?.toDouble(),
exposureSeconds = _exposureTimeToSeconds(dto.exposureTime),
lat = dto.latitude?.toDouble(),
long = dto.longitude?.toDouble(),
city = dto.city,
state = dto.state,
country = dto.country;
// stuff below is only required for caching as JSON
ExifInfo(
ExifInfo({
this.fileSize,
this.make,
this.model,
this.orientation,
this.lensModel,
this.fNumber,
this.focalLength,
this.lens,
this.f,
this.mm,
this.iso,
this.exposureTime,
this.exposureSeconds,
this.lat,
this.long,
this.city,
this.state,
this.country,
);
});
}
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
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"],
);
}
double? _exposureTimeToSeconds(String? s) {
if (s == 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;
}

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
import 'package:immich_mobile/shared/models/user.dart';
import 'package:isar/isar.dart';
import 'dart:convert';
@ -25,26 +26,28 @@ class Store {
/// Returns the stored value for the given key, or the default value if null
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
static Future<void> put<T>(StoreKey key, T value) {
_cache[key._id] = value;
return _db.writeTxn(() => _db.storeValues.put(StoreValue._of(value, key)));
_cache[key.id] = value;
return _db.writeTxn(
() async => _db.storeValues.put(await StoreValue._of(value, key)),
);
}
/// Removes the value synchronously from the cache and asynchronously from the DB
static Future<void> delete(StoreKey key) {
_cache[key._id] = null;
return _db.writeTxn(() => _db.storeValues.delete(key._id));
_cache[key.id] = null;
return _db.writeTxn(() => _db.storeValues.delete(key.id));
}
/// Fills the cache with the values from the DB
static _populateCache() {
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) {
_cache[key._id] = value._extract(key);
_cache[key.id] = value._extract(key);
}
}
}
@ -67,17 +70,22 @@ class StoreValue {
int? intValue;
String? strValue;
T? _extract<T>(StoreKey key) => key._isInt
? intValue
: (key._fromJson != null
? key._fromJson!(json.decode(strValue!))
T? _extract<T>(StoreKey key) => key.isInt
? (key.fromDb == null ? intValue : key.fromDb!.call(Store._db, intValue!))
: (key.fromJson != null
? key.fromJson!(json.decode(strValue!))
: strValue);
static StoreValue _of(dynamic value, StoreKey key) => StoreValue(
key._id,
intValue: key._isInt ? value : null,
strValue: key._isInt
static Future<StoreValue> _of(dynamic value, StoreKey key) async =>
StoreValue(
key.id,
intValue: key.isInt
? (key.toDb == null
? value
: await key.toDb!.call(Store._db, value))
: null,
strValue: key.isInt
? 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 {
userRemoteId(0),
assetETag(1),
currentUser(2, isInt: true, fromDb: _getUser, toDb: _toUser),
deviceIdHash(3, isInt: true),
deviceId(4),
;
// ignore: unused_element
const StoreKey(this._id, [this._isInt = false, this._fromJson]);
final int _id;
final bool _isInt;
final Function(dynamic)? _fromJson;
const StoreKey(
this.id, {
this.isInt = false,
this.fromDb,
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);
}

View file

@ -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';
part 'user.g.dart';
@Collection(inheritance: false)
class User {
User({
required this.id,
required this.updatedAt,
required this.email,
required this.firstName,
required this.lastName,
required this.profileImagePath,
required this.isAdmin,
required this.oauthId,
});
Id get isarId => fastHash(id);
User.fromDto(UserResponseDto dto)
: id = dto.id,
updatedAt = dto.updatedAt != null
? DateTime.parse(dto.updatedAt!).toUtc()
: DateTime.now().toUtc(),
email = dto.email,
firstName = dto.firstName,
lastName = dto.lastName,
profileImagePath = dto.profileImagePath,
isAdmin = dto.isAdmin,
oauthId = dto.oauthId;
isAdmin = dto.isAdmin;
@Index(unique: true, replace: false, type: IndexType.hash)
String id;
DateTime updatedAt;
String email;
String firstName;
String lastName;
String profileImagePath;
bool isAdmin;
String oauthId;
@Backlink(to: 'owner')
final IsarLinks<Album> albums = IsarLinks<Album>();
@Backlink(to: 'sharedUsers')
final IsarLinks<Album> sharedAlbums = IsarLinks<Album>();
@override
bool operator ==(other) {
if (other is! User) return false;
return id == other.id &&
updatedAt == other.updatedAt &&
email == other.email &&
firstName == other.firstName &&
lastName == other.lastName &&
profileImagePath == other.profileImagePath &&
isAdmin == other.isAdmin &&
oauthId == other.oauthId;
isAdmin == other.isAdmin;
}
@override
@ignore
int get hashCode =>
id.hashCode ^
updatedAt.hashCode ^
email.hashCode ^
firstName.hashCode ^
lastName.hashCode ^
profileImagePath.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;
}
isAdmin.hashCode;
}

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,19 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.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/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_cache.service.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/services/app_settings.service.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:immich_mobile/utils/tuple.dart';
import 'package:intl/intl.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.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> {
final AssetService _assetService;
final AssetCacheService _assetCacheService;
final AppSettingsService _settingsService;
final AlbumService _albumService;
final Isar _db;
final log = Logger('AssetNotifier');
final DeviceInfoService _deviceInfoService = DeviceInfoService();
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
AssetNotifier(
this._assetService,
this._assetCacheService,
this._settingsService,
this._albumService,
this._db,
) : super(AssetsState.fromAssetList([]));
Future<void> _updateAssetsState(
List<Asset> newAssetList, {
bool cache = true,
}) async {
if (cache) {
_assetCacheService.put(newAssetList);
}
Future<void> _updateAssetsState(List<Asset> newAssetList) async {
final layout = AssetGridLayoutParameters(
_settingsService.getSetting(AppSettingsEnum.tilesPerRow),
_settingsService.getSetting(AppSettingsEnum.dynamicLayout),
GroupAssetsBy.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
GroupAssetsBy
.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
);
state = await AssetsState.fromAssetList(newAssetList)
.withRenderDataStructure(layout);
}
// Just a little helper to trigger a rebuild of the state object
Future<void> rebuildAssetGridDataStructure() async {
await _updateAssetsState(state.allAssets, cache: false);
await _updateAssetsState(state.allAssets);
}
getAllAsset() async {
@ -104,127 +89,102 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final stopwatch = Stopwatch();
try {
_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();
if (isCacheValid && state.allAssets.isEmpty) {
final List<Asset>? cachedData = await _assetCacheService.get();
if (cachedData == null) {
isCacheValid = false;
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",
);
}
if (cachedCount > 0 && cachedCount != state.allAssets.length) {
await _updateAssetsState(await _getUserAssets(me.isarId));
log.info(
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
final remoteTask = _assetService.getRemoteAssets(
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;
final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums();
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (newRemote == null &&
(newLocal == null || currentLocal.equals(newLocal))) {
if (!newRemote && !newLocal) {
log.info("state is already up-to-date");
return;
}
newRemote ??= state.allAssets.slice(remoteBegin);
newLocal ??= [];
final combinedAssets = await _combineLocalAndRemoteAssets(
local: newLocal,
remote: newRemote,
);
await _updateAssetsState(combinedAssets);
log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
Store.put(StoreKey.assetETag, remoteResult.second);
stopwatch.reset();
final assets = await _getUserAssets(me.isarId);
if (!const ListEquality().equals(assets, state.allAssets)) {
log.info("setting new asset state");
await _updateAssetsState(assets);
}
} finally {
_getAllAssetInProgress = false;
}
}
static Future<List<Asset>> _computeCombine(
_CombineAssetsComputeParameters data,
) async {
var local = data.local;
var remote = data.remote;
final deviceId = data.deviceId;
Future<List<Asset>> _getUserAssets(int userId) => _db.assets
.filter()
.ownerIdEqualTo(userId)
.sortByFileCreatedAtDesc()
.findAll();
final List<Asset> assets = [];
if (remote.isNotEmpty && local.isNotEmpty) {
final Set<String> existingIds = remote
.where((e) => e.deviceId == deviceId)
.map((e) => e.deviceAssetId)
.toSet();
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<void> clearAllAsset() {
state = AssetsState.empty();
return _db.writeTxn(() async {
await _db.assets.clear();
await _db.exifInfos.clear();
await _db.albums.clear();
});
}
Future<List<Asset>> _combineLocalAndRemoteAssets({
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) {
Future<void> onNewAssetUploaded(Asset newAsset) async {
final int i = state.allAssets.indexWhere(
(a) =>
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) {
_updateAssetsState([...state.allAssets, newAsset]);
if (i == -1 ||
state.allAssets[i].localId != newAsset.localId ||
state.allAssets[i].deviceId != newAsset.deviceId) {
await _updateAssetsState([...state.allAssets, newAsset]);
} 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!
_updateAssetsState([
await _updateAssetsState([
...state.allAssets.slice(0, i),
...state.allAssets.slice(i + 1),
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;
try {
_updateAssetsState(
state.allAssets.whereNot(deleteAssets.contains).toList(),
);
final localDeleted = await _deleteLocalAssets(deleteAssets);
final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
final Set<String> deleted = HashSet();
deleted.addAll(localDeleted);
deleted.addAll(remoteDeleted);
if (deleted.isNotEmpty) {
_updateAssetsState(
state.allAssets.where((a) => !deleted.contains(a.id)).toList(),
);
if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
final dbIds = deleteAssets.map((e) => e.id).toList();
await _db.writeTxn(() async {
await _db.exifInfos.deleteAll(dbIds);
await _db.assets.deleteAll(dbIds);
});
}
} finally {
_deleteInProgress = false;
@ -232,16 +192,15 @@ class AssetNotifier extends StateNotifier<AssetsState> {
}
Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
var deviceInfo = await _deviceInfoService.getDeviceInfo();
var deviceId = deviceInfo["deviceId"];
final int deviceId = Store.get(StoreKey.deviceIdHash);
final List<String> local = [];
// Delete asset from device
for (final Asset asset in assetsToDelete) {
if (asset.isLocal) {
local.add(asset.localId!);
local.add(asset.localId);
} else if (asset.deviceId == deviceId) {
// 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) {
local.add(localAsset.id);
}
@ -249,7 +208,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
}
if (local.isNotEmpty) {
try {
return await PhotoManager.editor.deleteWithIds(local);
await PhotoManager.editor.deleteWithIds(local);
} catch (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) {
return AssetNotifier(
ref.watch(assetServiceProvider),
ref.watch(assetCacheServiceProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
);
});

View file

@ -28,9 +28,13 @@ class ApiService {
debugPrint("Cannot init ApiServer endpoint, userInfoBox not open yet.");
}
}
String? _authToken;
setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint);
if (_authToken != null) {
setAccessToken(_authToken!);
}
userApi = UserApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient);
oAuthApi = OAuthApi(_apiClient);
@ -94,6 +98,9 @@ class ApiService {
}
setAccessToken(String accessToken) {
_authToken = accessToken;
_apiClient.addDefaultHeader('Authorization', 'Bearer $accessToken');
}
ApiClient get apiClient => _apiClient;
}

View file

@ -1,101 +1,84 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hive/hive.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/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/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/sync.service.dart';
import 'package:immich_mobile/utils/openapi_extensions.dart';
import 'package:immich_mobile/utils/tuple.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final assetServiceProvider = Provider(
(ref) => AssetService(
ref.watch(apiServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(backgroundServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
),
);
class AssetService {
final ApiService _apiService;
final BackupService _backupService;
final BackgroundService _backgroundService;
final SyncService _syncService;
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
Future<Pair<List<Asset>?, String?>> getRemoteAssets({String? etag}) async {
Future<List<AssetResponseDto>?> _getRemoteAssets({
required bool hasCache,
}) async {
try {
// temporary fix for race condition that the _apiService
// get called before accessToken is set
var userInfoHiveBox = await Hive.openBox(userInfoBox);
var accessToken = userInfoHiveBox.get(accessTokenKey);
_apiService.setAccessToken(accessToken);
final etag = hasCache ? Store.get(StoreKey.assetETag) : null;
final Pair<List<AssetResponseDto>, String?>? remote =
await _apiService.assetApi.getAllAssetsWithETag(eTag: etag);
if (remote == null) {
return Pair(null, etag);
return null;
}
return Pair(
remote.first.map(Asset.remote).toList(growable: false),
remote.second,
);
if (remote.second != null && remote.second != etag) {
Store.put(StoreKey.assetETag, remote.second);
}
return remote.first;
} catch (e, stack) {
log.severe('Error while getting remote assets', e, stack);
debugPrint("[ERROR] [getRemoteAssets] $e");
return Pair(null, etag);
return null;
}
}
/// 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(
Iterable<Asset> deleteAssets,
) 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(
Asset asset,
UpdateAssetDto updateAssetDto,

View file

@ -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/services/json_cache.dart';
@Deprecated("only kept to remove its files after migration")
class AssetCacheService extends JsonCache<List<Asset>> {
AssetCacheService() : super("asset_cache");
static Future<List<Map<String, dynamic>>> _computeSerialize(
List<Asset> assets,
) async {
return assets.map((e) => e.toJson()).toList();
}
@override
void put(List<Asset> data) {}
@override
void put(List<Asset> data) async {
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;
}
}
Future<List<Asset>?> get() => Future.value(null);
}
final assetCacheServiceProvider = Provider(
(ref) => AssetCacheService(),
);

View file

@ -1,9 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
@Deprecated("only kept to remove its files after migration")
abstract class JsonCache<T> {
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);
Future<T?> get();
}

View 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();
}

View file

@ -3,24 +3,32 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:http_parser/http_parser.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/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/sync.service.dart';
import 'package:immich_mobile/utils/files_helper.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
final userServiceProvider = Provider(
(ref) => UserService(
ref.watch(apiServiceProvider),
ref.watch(dbProvider),
ref.watch(syncServiceProvider),
),
);
class UserService {
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 {
final dto = await _apiService.userApi.getAllUsers(isAll);
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 {
try {
var mimeType = FileHelper.getMimeType(image.path);
@ -50,4 +66,12 @@ class UserService {
return null;
}
}
Future<bool> refreshUsers() async {
final List<User>? users = await _getAllUsers(isAll: true);
if (users == null) {
return false;
}
return _syncService.syncUsersFromServer(users);
}
}

View 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;
}
}

View file

@ -5,7 +5,11 @@ extension DurationExtension on String {
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);
}
double? toDouble() {
return double.tryParse(this);
double toDouble() {
return double.parse(this);
}
int toInt() {
return int.parse(this);
}
}

View 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;
}

View 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;
}

View file

@ -31,20 +31,20 @@ String getAlbumThumbnailUrl(
final Album album, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
if (album.albumThumbnailAssetId == null) {
if (album.thumbnail.value?.remoteId == null) {
return '';
}
return _getThumbnailUrl(album.albumThumbnailAssetId!, type: type);
return _getThumbnailUrl(album.thumbnail.value!.remoteId!, type: type);
}
String getAlbumThumbNailCacheKey(
final Album album, {
ThumbnailFormat type = ThumbnailFormat.WEBP,
}) {
if (album.albumThumbnailAssetId == null) {
if (album.thumbnail.value?.remoteId == null) {
return '';
}
return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type);
return _getThumbnailCacheKey(album.thumbnail.value!.remoteId!, type);
}
String getImageUrl(final Asset asset) {

View file

@ -1,7 +1,11 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:flutter/cupertino.dart';
import 'package:hive/hive.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/services/asset_cache.service.dart';
Future<void> migrateHiveToStoreIfNecessary() async {
try {
@ -22,3 +26,9 @@ _migrateSingleKey(Box box, String hiveKey, StoreKey key) async {
await box.delete(hiveKey);
}
}
Future<void> migrateJsonCacheIfNecessary() async {
await AlbumCacheService().invalidate();
await SharedAlbumCacheService().invalidate();
await AssetCacheService().invalidate();
}

View file

@ -6,3 +6,13 @@ class Pair<T1, T2> {
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);
}

View file

@ -17,6 +17,7 @@ Name | Type | Description | Notes
**shouldChangePassword** | **bool** | |
**isAdmin** | **bool** | |
**deletedAt** | [**DateTime**](DateTime.md) | | [optional]
**updatedAt** | **String** | | [optional]
**oauthId** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -22,6 +22,7 @@ class UserResponseDto {
required this.shouldChangePassword,
required this.isAdmin,
this.deletedAt,
this.updatedAt,
required this.oauthId,
});
@ -49,6 +50,14 @@ class UserResponseDto {
///
DateTime? deletedAt;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? updatedAt;
String oauthId;
@override
@ -62,6 +71,7 @@ class UserResponseDto {
other.shouldChangePassword == shouldChangePassword &&
other.isAdmin == isAdmin &&
other.deletedAt == deletedAt &&
other.updatedAt == updatedAt &&
other.oauthId == oauthId;
@override
@ -76,10 +86,11 @@ class UserResponseDto {
(shouldChangePassword.hashCode) +
(isAdmin.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) +
(updatedAt == null ? 0 : updatedAt!.hashCode) +
(oauthId.hashCode);
@override
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, oauthId=$oauthId]';
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -95,6 +106,11 @@ class UserResponseDto {
json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String();
} else {
// json[r'deletedAt'] = null;
}
if (this.updatedAt != null) {
json[r'updatedAt'] = this.updatedAt;
} else {
// json[r'updatedAt'] = null;
}
json[r'oauthId'] = this.oauthId;
return json;
@ -128,6 +144,7 @@ class UserResponseDto {
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
deletedAt: mapDateTime(json, r'deletedAt', ''),
updatedAt: mapValueOfType<String>(json, r'updatedAt'),
oauthId: mapValueOfType<String>(json, r'oauthId')!,
);
}

View file

@ -61,6 +61,11 @@ void main() {
// TODO
});
// String updatedAt
test('to test the property `updatedAt`', () async {
// TODO
});
// String oauthId
test('to test the property `oauthId`', () async {
// TODO

View file

@ -13,14 +13,16 @@ void main() {
testAssets.add(
Asset(
deviceAssetId: '$i',
deviceId: '',
ownerId: '',
localId: '$i',
deviceId: 1,
ownerId: 1,
fileCreatedAt: date,
fileModifiedAt: date,
updatedAt: date,
durationInSeconds: 0,
fileName: '',
isFavorite: false,
isLocal: false,
),
);
}

View 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]);
});
});
}

View file

@ -12,75 +12,81 @@ import 'package:mockito/mockito.dart';
])
import 'favorite_provider_test.mocks.dart';
Asset _getTestAsset(String id, bool favorite) {
return Asset(
remoteId: id,
deviceAssetId: '',
deviceId: '',
ownerId: '',
Asset _getTestAsset(int id, bool favorite) {
final Asset a = Asset(
remoteId: id.toString(),
localId: id.toString(),
deviceId: 1,
ownerId: 1,
fileCreatedAt: DateTime.now(),
fileModifiedAt: DateTime.now(),
updatedAt: DateTime.now(),
isLocal: false,
durationInSeconds: 0,
fileName: '',
isFavorite: favorite,
);
a.id = id;
return a;
}
void main() {
group("Test favoriteProvider", () {
late MockAssetsState assetsState;
late MockAssetNotifier assetNotifier;
late ProviderContainer container;
late StateNotifierProvider<FavoriteSelectionNotifier, Set<String>> testFavoritesProvider;
late StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>
testFavoritesProvider;
setUp(() {
assetsState = MockAssetsState();
assetNotifier = MockAssetNotifier();
container = ProviderContainer();
setUp(
() {
assetsState = MockAssetsState();
assetNotifier = MockAssetNotifier();
container = ProviderContainer();
testFavoritesProvider =
StateNotifierProvider<FavoriteSelectionNotifier, Set<String>>((ref) {
return FavoriteSelectionNotifier(
assetsState,
assetNotifier,
);
});
},);
testFavoritesProvider =
StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) {
return FavoriteSelectionNotifier(
assetsState,
assetNotifier,
);
});
},
);
test("Empty favorites provider", () {
when(assetsState.allAssets).thenReturn([]);
expect(<String>{}, container.read(testFavoritesProvider));
expect(<int>{}, container.read(testFavoritesProvider));
});
test("Non-empty favorites provider", () {
when(assetsState.allAssets).thenReturn([
_getTestAsset("001", false),
_getTestAsset("002", true),
_getTestAsset("003", false),
_getTestAsset("004", false),
_getTestAsset("005", true),
_getTestAsset(1, false),
_getTestAsset(2, true),
_getTestAsset(3, false),
_getTestAsset(4, false),
_getTestAsset(5, true),
]);
expect(<String>{"002", "005"}, container.read(testFavoritesProvider));
expect(<int>{2, 5}, container.read(testFavoritesProvider));
});
test("Toggle favorite", () {
when(assetNotifier.toggleFavorite(null, false))
.thenAnswer((_) async => false);
final testAsset1 = _getTestAsset("001", false);
final testAsset2 = _getTestAsset("002", true);
final testAsset1 = _getTestAsset(1, false);
final testAsset2 = _getTestAsset(2, true);
when(assetsState.allAssets).thenReturn([testAsset1, testAsset2]);
expect(<String>{"002"}, container.read(testFavoritesProvider));
expect(<int>{2}, container.read(testFavoritesProvider));
container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset2);
expect(<String>{}, container.read(testFavoritesProvider));
expect(<int>{}, container.read(testFavoritesProvider));
container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset1);
expect(<String>{"001"}, container.read(testFavoritesProvider));
expect(<int>{1}, container.read(testFavoritesProvider));
});
test("Add favorites", () {
@ -89,16 +95,16 @@ void main() {
when(assetsState.allAssets).thenReturn([]);
expect(<String>{}, container.read(testFavoritesProvider));
expect(<int>{}, container.read(testFavoritesProvider));
container.read(testFavoritesProvider.notifier).addToFavorites(
[
_getTestAsset("001", false),
_getTestAsset("002", false),
_getTestAsset(1, false),
_getTestAsset(2, false),
],
);
expect(<String>{"001", "002"}, container.read(testFavoritesProvider));
expect(<int>{1, 2}, container.read(testFavoritesProvider));
});
});
}

View file

@ -187,7 +187,7 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier {
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
void onNewAssetUploaded(_i4.Asset? newAsset) => super.noSuchMethod(
Future<void> onNewAssetUploaded(_i4.Asset? newAsset) => super.noSuchMethod(
Invocation.method(
#onNewAssetUploaded,
[newAsset],
@ -195,7 +195,7 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier {
returnValueForMissingStub: null,
);
@override
dynamic deleteAssets(Set<_i4.Asset>? deleteAssets) => super.noSuchMethod(
Future<void> deleteAssets(Set<_i4.Asset> deleteAssets) => super.noSuchMethod(
Invocation.method(
#deleteAssets,
[deleteAssets],

View file

@ -101,6 +101,7 @@ describe('User', () => {
shouldChangePassword: true,
profileImagePath: '',
deletedAt: null,
updatedAt: expect.anything(),
oauthId: '',
},
{
@ -113,6 +114,7 @@ describe('User', () => {
shouldChangePassword: true,
profileImagePath: '',
deletedAt: null,
updatedAt: expect.anything(),
oauthId: '',
},
]),

View file

@ -3583,6 +3583,9 @@
"format": "date-time",
"type": "string"
},
"updatedAt": {
"type": "string"
},
"oauthId": {
"type": "string"
}

View file

@ -10,6 +10,7 @@ export class UserResponseDto {
shouldChangePassword!: boolean;
isAdmin!: boolean;
deletedAt?: Date;
updatedAt?: string;
oauthId!: string;
}
@ -24,6 +25,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin,
deletedAt: entity.deletedAt,
updatedAt: entity.updatedAt,
oauthId: entity.oauthId,
};
}

View file

@ -100,6 +100,7 @@ const adminUserResponse = Object.freeze({
shouldChangePassword: false,
profileImagePath: '',
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
});
describe(UserService.name, () => {
@ -162,6 +163,7 @@ describe(UserService.name, () => {
shouldChangePassword: false,
profileImagePath: '',
createdAt: '2021-01-01',
updatedAt: '2021-01-01',
},
]);
});

View file

@ -2406,6 +2406,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto
*/
'deletedAt'?: string;
/**
*
* @type {string}
* @memberof UserResponseDto
*/
'updatedAt'?: string;
/**
*
* @type {string}