diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs index d5da8f8445c2d6..3d69d1f503cd5f 100644 --- a/e2e/eslint.config.mjs +++ b/e2e/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/mobile/lib/domain/models/person.model.dart b/mobile/lib/domain/models/person.model.dart index 56e6d3990dcb2b..94ead6a2e9850a 100644 --- a/mobile/lib/domain/models/person.model.dart +++ b/mobile/lib/domain/models/person.model.dart @@ -3,32 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'person.model.freezed.dart'; -// TODO: Remove PersonDto once Isar is removed @freezed -abstract class PersonDto with _$PersonDto { - const factory PersonDto({ - required String id, - DateTime? birthDate, - required bool isHidden, - required String name, - required String thumbnailPath, - DateTime? updatedAt, - }) = _PersonDto; -} - -// Model for a person stored in the server -@freezed -abstract class DriftPerson with _$DriftPerson { - const factory DriftPerson({ - required String id, - required DateTime createdAt, - required DateTime updatedAt, - required String ownerId, - required String name, - String? faceAssetId, - required bool isFavorite, - required bool isHidden, - required String? color, - DateTime? birthDate, - }) = _DriftPerson; +abstract class Person with _$Person { + const factory Person({required String id, required String name, DateTime? updatedAt, DateTime? birthDate}) = _Person; } diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart index 73972dbdc0ea3a..196cf1edbb1971 100644 --- a/mobile/lib/domain/services/people.service.dart +++ b/mobile/lib/domain/services/people.service.dart @@ -10,16 +10,16 @@ class DriftPeopleService { const DriftPeopleService(this._repository, this._personApiRepository); - Future get(String personId) { + Future get(String personId) { return _repository.get(personId); } - Future> getAssetPeople(String assetId) { + Future> getAssetPeople(String assetId) { return _repository.getAssetPeople(assetId); } - Future> getAllPeople({int minFaces = 3}) { - return _repository.getAllPeople(minFaces: minFaces); + Stream> watch({int minFaces = 3}) { + return _repository.watch(minFaces: minFaces); } Future updateName(String personId, String name) async { @@ -27,7 +27,7 @@ class DriftPeopleService { return _repository.updateName(personId, name); } - Future updateBrithday(String personId, DateTime birthday) async { + Future updateBirthday(String personId, DateTime birthday) async { await _personApiRepository.update(personId, birthday: birthday); return _repository.updateBirthday(personId, birthday); } diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index 0dd73cc81dd897..c5c7bfdb9cd0e7 100644 --- a/mobile/lib/infrastructure/repositories/people.repository.dart +++ b/mobile/lib/infrastructure/repositories/people.repository.dart @@ -8,14 +8,14 @@ class DriftPeopleRepository extends DriftDatabaseRepository { final Drift _db; const DriftPeopleRepository(this._db) : super(_db); - Future get(String personId) async { + Future get(String personId) async { final query = _db.select(_db.personEntity)..where((row) => row.id.equals(personId)); final result = await query.getSingleOrNull(); return result?.toDto(); } - Future> getAssetPeople(String assetId) async { + Future> getAssetPeople(String assetId) async { // An asset can have multiple face records for the same person (e.g., metadata // imports alongside ML detections). Use a subquery instead of a join so each // person is returned once, regardless of how many of their faces are on the asset @@ -33,7 +33,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } - Future> getAllPeople({int minFaces = 3}) async { + Stream> watch({int minFaces = 3}) { final people = _db.personEntity; final faces = _db.assetFaceEntity; final assets = _db.remoteAssetEntity; @@ -59,7 +59,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) { final person = row.readTable(people); return person.toDto(); - }).get(); + }).watch(); } Future updateName(String personId, String name) { @@ -76,18 +76,5 @@ class DriftPeopleRepository extends DriftDatabaseRepository { } extension on PersonEntityData { - DriftPerson toDto() { - return DriftPerson( - id: id, - createdAt: createdAt, - updatedAt: updatedAt, - ownerId: ownerId, - name: name, - faceAssetId: faceAssetId, - isFavorite: isFavorite, - isHidden: isHidden, - color: color, - birthDate: birthDate, - ); - } + Person toDto() => Person(id: id, updatedAt: updatedAt, name: name, birthDate: birthDate); } diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 7e5e1b819e3c1b..a9cc13ba6a196b 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -194,7 +194,7 @@ class SearchFilter { String? language; String? assetId; List? tagIds; - Set people; + Set people; SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; @@ -250,7 +250,7 @@ class SearchFilter { String? language, String? ocr, String? assetId, - Set? people, + Set? people, List? tagIds, SearchLocationFilter? location, SearchCameraFilter? camera, diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index ad2674b81bdeb2..a0e55e58dea1ca 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -316,12 +316,11 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final backupAlbums = ref.watch(backupAlbumProvider); + Widget buildSelectedAlbumName() { String text = context.t.backup_controller_page_backup_selected; - final albums = ref - .read(backupAlbumProvider) - .where((album) => album.backupSelection == BackupSelection.selected) - .toList(); + final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.selected).toList(); if (albums.isNotEmpty) { for (var album in albums) { @@ -352,10 +351,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { Widget buildExcludedAlbumName() { String text = context.t.backup_controller_page_excluded; - final albums = ref - .read(backupAlbumProvider) - .where((album) => album.backupSelection == BackupSelection.excluded) - .toList(); + final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.excluded).toList(); if (albums.isNotEmpty) { for (var album in albums) { diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 5b620e9c914727..240c4abe7a1084 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -12,7 +12,6 @@ import 'package:immich_mobile/presentation/pages/search/paginated_search.provide import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; @@ -132,7 +131,6 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { // Library page if (index == kLibraryTabIndex) { ref.invalidate(localAlbumProvider); - ref.invalidate(driftGetAllPeopleProvider); } ref.read(hapticFeedbackProvider.notifier).selectionClick(); diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index ea7423354a35d3..23a2ef2a2f08ff 100644 --- a/mobile/lib/presentation/actions/delete.action.dart +++ b/mobile/lib/presentation/actions/delete.action.dart @@ -37,7 +37,8 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); // Assets already in the trash or in the locked folder are deleted outright, irrespective of the server setting. - final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked); + final trash = + ownedRemote.isEmpty || (trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked)); return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash); }, dependencies: [assetsActionProvider]); diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 59cabf8dafb3bb..078b9cc3252492 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -32,6 +32,8 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { final userId = ref.watch(authProvider).userId; final activityEnabled = useState(album.isActivityEnabled); final isOwner = album.ownerId == userId; + final owner = isOwner ? ref.watch(currentUserProvider) : null; + final allUsers = isOwner ? null : ref.watch(driftUsersProvider); void showErrorMessage() { ContextHelper(context).pop(); @@ -141,7 +143,6 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { Widget buildOwnerInfo() { if (isOwner) { - final owner = ref.read(currentUserProvider); return ListTile( leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(), title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)), @@ -149,8 +150,11 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { trailing: Text(context.t.owner, style: context.textTheme.labelLarge), ); } else { - final usersProvider = ref.read(driftUsersProvider); - return usersProvider.maybeWhen( + if (allUsers == null) { + return const SizedBox(); + } + + return allUsers.maybeWhen( data: (users) { final user = users.firstWhereOrNull((u) => u.id == album.ownerId); diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 3118f2c4f04555..137f46659f8cab 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -145,7 +145,7 @@ class _PeopleCollectionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final people = ref.watch(driftGetAllPeopleProvider); + final people = ref.watch(getAllPeopleProvider); return LayoutBuilder( builder: (context, constraints) { diff --git a/mobile/lib/presentation/pages/drift_people_collection.page.dart b/mobile/lib/presentation/pages/drift_people_collection.page.dart index cb305395ccf02a..569cb99b01d6bb 100644 --- a/mobile/lib/presentation/pages/drift_people_collection.page.dart +++ b/mobile/lib/presentation/pages/drift_people_collection.page.dart @@ -33,7 +33,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState { - late DriftPerson _person; + late Person _person; @override void initState() { diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart index 824d7d7c6b0735..2662c60a30aaf8 100644 --- a/mobile/lib/presentation/pages/drift_slideshow.page.dart +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -295,14 +296,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si color: context.colorScheme.primary, ); } else { - return LinearProgressIndicator( - color: context.colorScheme.primary, - borderRadius: BorderRadius.zero, - minHeight: 5, - value: - ref.read(videoPlayerProvider(asset.id).select((s) => s.position)).inMilliseconds / - asset.duration.inMilliseconds, - ); + return _VideoProgressBar(asset: asset); } } @@ -374,25 +368,13 @@ class _DriftSlideshowPageState extends ConsumerState with Si builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)), ); } else { - final status = ref.read(videoPlayerProvider(asset.id).select((s) => s.status)); - final position = ref.read(videoPlayerProvider(asset.id)).position; - - if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) { - unawaited(_nextPage()); - } else if (status == VideoPlaybackStatus.playing) { - unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false)); - } - - return PhotoView.customChild( - onTapUp: (_, _, _) => _onTapUp(), - disableScaleGestures: true, - filterQuality: FilterQuality.high, - initialScale: scale, - child: NativeVideoViewer( - asset: asset, - isCurrent: isCurrent, - image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), - ), + return _VideoChild( + asset: asset, + isCurrent: isCurrent, + scale: scale, + imageProvider: imageProvider, + onTapUp: _onTapUp, + onCompleted: _nextPage, ); } } @@ -477,6 +459,67 @@ class _DriftSlideshowPageState extends ConsumerState with Si } } +class _VideoChild extends ConsumerWidget { + final BaseAsset asset; + final bool isCurrent; + final PhotoViewComputedScale scale; + final ImageProvider imageProvider; + final VoidCallback onTapUp; + final VoidCallback onCompleted; + + const _VideoChild({ + required this.asset, + required this.isCurrent, + required this.scale, + required this.imageProvider, + required this.onTapUp, + required this.onCompleted, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) { + if (status == VideoPlaybackStatus.completed) { + if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) { + onCompleted(); + } + } else if (status == VideoPlaybackStatus.playing) { + unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false)); + } + }); + + return PhotoView.customChild( + onTapUp: (_, _, _) => onTapUp(), + disableScaleGestures: true, + filterQuality: FilterQuality.high, + initialScale: scale, + child: NativeVideoViewer( + asset: asset, + isCurrent: isCurrent, + image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), + ), + ); + } +} + +class _VideoProgressBar extends ConsumerWidget { + final BaseAsset asset; + + const _VideoProgressBar({required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position)); + + return LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: BorderRadius.zero, + minHeight: 5, + value: position.inMilliseconds / asset.duration.inMilliseconds, + ); + } +} + /// Progress bar for image slides, driven by an explicit [AnimationController]. /// /// [TweenAnimationBuilder] creates its controller internally with the default diff --git a/mobile/lib/presentation/pages/drift_user_selection.page.dart b/mobile/lib/presentation/pages/drift_user_selection.page.dart index 215a5d1071ccc6..c11d5a1fd6840e 100644 --- a/mobile/lib/presentation/pages/drift_user_selection.page.dart +++ b/mobile/lib/presentation/pages/drift_user_selection.page.dart @@ -52,6 +52,7 @@ class DriftUserSelectionPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final AsyncValue> suggestedShareUsers = ref.watch(driftUsersProvider); + final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id)); final sharedUsersList = useState>({}); void addNewUsersHandler() { @@ -136,9 +137,6 @@ class DriftUserSelectionPage extends HookConsumerWidget { ), body: suggestedShareUsers.widgetWhen( onData: (users) { - // Get shared users for this album from the database - final sharedUsers = ref.read(remoteAlbumSharedUsersProvider(album.id)); - return sharedUsers.when( data: (albumSharedUsers) { // Filter out users that are already shared with this album and the owner diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 0c6b0d8ae4ee95..e364e0d2f8efd3 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -129,7 +129,7 @@ class DriftSearchPage extends HookConsumerWidget { void showPeoplePicker() { var people = filter.value.people; - void handleOnSelect(Set value) { + void handleOnSelect(Set value) { people = value; } @@ -742,9 +742,7 @@ class _SearchResultGrid extends ConsumerWidget { return false; } - Widget? _bottomWidget(BuildContext context, WidgetRef ref) { - final isLoading = ref.read(paginatedSearchProvider.select((s) => s.isLoading)); - + Widget? _bottomWidget(BuildContext context, {required bool isLoading, required bool hasMore}) { if (isLoading) { return const SliverFillRemaining( hasScrollBody: false, @@ -755,8 +753,6 @@ class _SearchResultGrid extends ConsumerWidget { ); } - final hasMore = ref.read(paginatedSearchProvider.select((s) => s.nextPage != null)); - if (hasMore) { return null; } @@ -778,6 +774,7 @@ class _SearchResultGrid extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty)); final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); + final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null)); if (!hasAssets && !isLoading) { return const _SearchNoResults(); @@ -807,7 +804,7 @@ class _SearchResultGrid extends ConsumerWidget { bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), snapToMonth: false, loadingWidget: const SizedBox.shrink(), - bottomSliverWidget: _bottomWidget(context, ref), + bottomSliverWidget: _bottomWidget(context, isLoading: isLoading, hasMore: hasMore), ), ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 739fe0a0d9d6e0..00c20f35bda56d 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -30,7 +30,7 @@ class PeopleDetails extends ConsumerWidget { final peopleFuture = ref.watch(driftPeopleAssetProvider(asset.id)); - Future showNameEditModal(DriftPerson person) async { + Future showNameEditModal(Person person) async { await showDialog( context: context, useRootNavigator: false, @@ -96,7 +96,7 @@ class PeopleDetails extends ConsumerWidget { } class _Avatar extends StatelessWidget { - final DriftPerson person; + final Person person; final DateTime assetFileCreatedAt; final VoidCallback? onTap; final VoidCallback? onNameTap; diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart index 0b8a08a5d83eec..e56a21e02120ee 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:scroll_date_picker/scroll_date_picker.dart'; class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonBirthdayEditForm({super.key, required this.person}); @@ -30,14 +30,9 @@ class _DriftPersonNameEditFormState extends ConsumerState saveBirthday() async { try { - final result = await ref.read(driftPeopleServiceProvider).updateBrithday(widget.person.id, _selectedDate); - - if (result != 0) { - ref.invalidate(driftGetAllPeopleProvider); - if (!mounted) { - return; - } + final result = await ref.read(driftPeopleServiceProvider).updateBirthday(widget.person.id, _selectedDate); + if (result != 0 && mounted) { context.pop(_selectedDate); } } catch (error) { diff --git a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart index 3b0e62ab1a8d2c..4ae41bc640541d 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class DriftPersonNameEditForm extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonNameEditForm({super.key, required this.person}); @@ -29,12 +29,7 @@ class _DriftPersonNameEditFormState extends ConsumerState onEdit(String personId, String newName) async { try { final result = await ref.read(driftPeopleServiceProvider).updateName(personId, newName); - if (result != 0) { - ref.invalidate(driftGetAllPeopleProvider); - if (!mounted) { - return; - } - + if (result != 0 && mounted) { context.pop(newName); } } catch (error) { diff --git a/mobile/lib/providers/infrastructure/people.provider.dart b/mobile/lib/providers/infrastructure/people.provider.dart index 1939940975d46a..ae24f044e8331d 100644 --- a/mobile/lib/providers/infrastructure/people.provider.dart +++ b/mobile/lib/providers/infrastructure/people.provider.dart @@ -14,13 +14,13 @@ final driftPeopleServiceProvider = Provider( (ref) => DriftPeopleService(ref.watch(driftPeopleRepositoryProvider), ref.watch(personApiRepositoryProvider)), ); -final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async { +final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async { final service = ref.watch(driftPeopleServiceProvider); return service.getAssetPeople(assetId); }); -final driftGetAllPeopleProvider = FutureProvider>((ref) async { +final getAllPeopleProvider = StreamProvider>((ref) async* { final service = ref.watch(driftPeopleServiceProvider); final prefs = await ref.watch(userMetadataPreferencesProvider.future); - return service.getAllPeople(minFaces: prefs?.minimumFaces ?? 3); + yield* service.watch(minFaces: prefs?.minimumFaces ?? 3); }); diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart deleted file mode 100644 index 1bd58509f5c9a0..00000000000000 --- a/mobile/lib/providers/search/people.provider.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/services/person.service.dart'; - -final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async { - final PersonService personService = ref.read(personServiceProvider); - - final people = await personService.getAllPeople(); - - return people; -}); - -final updatePersonNameProvider = FutureProvider.autoDispose( - (ref) => (String personId, String updatedName) async { - final PersonService personService = ref.read(personServiceProvider); - final person = await personService.updateName(personId, updatedName); - - if (person != null && person.name == updatedName) { - ref.invalidate(getAllPeopleProvider); - return true; - } - return false; - }, -); diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 158e3ac3330662..262868eea18bd5 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -11,12 +11,7 @@ class PersonApiRepository extends ApiRepository { PersonApiRepository(this._api); - Future> getAll() async { - final dto = await checkNull(_api.getAllPeople()); - return dto.people.map(_toPerson).toList(); - } - - Future update(String id, {String? name, DateTime? birthday}) async { + Future update(String id, {String? name, DateTime? birthday}) async { final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day); final dto = PersonUpdateDto( name: name == null ? const Optional.absent() : Optional.present(name), @@ -26,12 +21,6 @@ class PersonApiRepository extends ApiRepository { return _toPerson(response); } - static PersonDto _toPerson(PersonResponseDto dto) => PersonDto( - birthDate: dto.birthDate, - id: dto.id, - isHidden: dto.isHidden, - name: dto.name, - thumbnailPath: dto.thumbnailPath, - updatedAt: dto.updatedAt.orElse(null), - ); + static Person _toPerson(PersonResponseDto dto) => + .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.orElse(null)); } diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart deleted file mode 100644 index 0d589ea71d5560..00000000000000 --- a/mobile/lib/services/person.service.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/repositories/person_api.repository.dart'; -import 'package:logging/logging.dart'; - -final personServiceProvider = Provider.autoDispose( - (ref) => PersonService(ref.watch(personApiRepositoryProvider)), -); - -class PersonService { - final Logger _log = Logger("PersonService"); - final PersonApiRepository _personApiRepository; - PersonService(this._personApiRepository); - - Future> getAllPeople() async { - try { - return await _personApiRepository.getAll(); - } catch (error, stack) { - _log.severe("Error while fetching curated people", error, stack); - return []; - } - } - - Future updateName(String id, String name) async { - try { - return await _personApiRepository.update(id, name: name); - } catch (error, stack) { - _log.severe("Error while updating person name", error, stack); - } - return null; - } -} diff --git a/mobile/lib/utils/people.utils.dart b/mobile/lib/utils/people.utils.dart index 8da5a5a3eb106b..bd2b607e0b0900 100644 --- a/mobile/lib/utils/people.utils.dart +++ b/mobile/lib/utils/people.utils.dart @@ -34,7 +34,7 @@ int _calculateAgeInMonths(DateTime birthDate, DateTime referenceDate) { (referenceDate.day < birthDate.day ? 1 : 0); } -Future showNameEditModal(BuildContext context, DriftPerson person) { +Future showNameEditModal(BuildContext context, Person person) { return showDialog( context: context, useRootNavigator: false, @@ -44,7 +44,7 @@ Future showNameEditModal(BuildContext context, DriftPerson person) { ); } -Future showBirthdayEditModal(BuildContext context, DriftPerson person) { +Future showBirthdayEditModal(BuildContext context, Person person) { return showDialog( context: context, useRootNavigator: false, diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 4c6b2a7211c1ea..6a119f8ca3eea3 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -177,7 +177,15 @@ class _BackupIndicator extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final indicatorIcon = _getBackupBadgeIcon(context, ref); + final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled)); + final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none)); + final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); + final indicatorIcon = _getBackupBadgeIcon( + context, + backupEnabled: backupEnabled, + hasError: hasError, + isUploading: isUploading, + ); return IconButton( onPressed: () => context.pushRoute(const DriftBackupRoute()), @@ -192,12 +200,14 @@ class _BackupIndicator extends ConsumerWidget { ); } - Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) { - final backupEnabled = ref.read(appConfigProvider.select((c) => c.backup.enabled)); - final hasError = ref.read(driftBackupProvider.select((state) => state.error != BackupError.none)); + Widget? _getBackupBadgeIcon( + BuildContext context, { + required bool backupEnabled, + required bool hasError, + required bool isUploading, + }) { final isDarkTheme = context.isDarkTheme; final iconColor = isDarkTheme ? Colors.white : Colors.black; - final isUploading = ref.read(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); if (!backupEnabled) { return _BadgeLabel( diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index 4b159447e66a8d..56eb3c6094c993 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -28,7 +28,7 @@ class PersonSliverAppBar extends ConsumerStatefulWidget { required this.onBirthdayTap, }); - final DriftPerson person; + final Person person; final VoidCallback onNameTap; final VoidCallback onBirthdayTap; final VoidCallback onShowOptions; @@ -137,7 +137,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState { class _ExpandedBackground extends ConsumerStatefulWidget { final double scrollProgress; - final DriftPerson person; + final Person person; final VoidCallback onNameTap; final VoidCallback onBirthdayTap; diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index b4e73e97f9d833..ee738265b2449d 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -8,15 +8,15 @@ import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -import 'package:immich_mobile/providers/search/people.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; class PeoplePicker extends HookConsumerWidget { const PeoplePicker({super.key, required this.onSelect, this.filter}); - final Function(Set) onSelect; - final Set? filter; + final Function(Set) onSelect; + final Set? filter; @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,7 +24,7 @@ class PeoplePicker extends HookConsumerWidget { const imageSize = 60.0; final searchQuery = useState(''); final people = ref.watch(getAllPeopleProvider); - final selectedPeople = useState>(filter ?? {}); + final selectedPeople = useState>(filter ?? {}); return Column( children: [ @@ -65,7 +65,7 @@ class PeoplePicker extends HookConsumerWidget { padding: const EdgeInsets.only(bottom: 2.0), child: LargeLeadingTile( title: Text( - person.name, + person.name.nullIfEmpty ?? context.t.no_name, style: context.textTheme.bodyLarge?.copyWith( fontSize: 20, fontWeight: FontWeight.w500, diff --git a/mobile/mise.toml b/mobile/mise.toml index f021cd8747e016..d8ddc8ab211faf 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -111,6 +111,18 @@ run = [ { task = "install", args = ["--enforce-lockfile"] }, ] +[tasks.checkout] +description = "Bring a checkout up to date after switching branches" +run = [ + { task = "install" }, + { task = "checkout:ios" }, + { task = "codegen" }, +] + +[tasks."checkout:ios"] +description = "Bring the iOS project up to date after switching branches" +run = "bash ./scripts/checkout_ios.sh" + [tasks.start] alias = "start" description = "Start flutter app" @@ -139,7 +151,7 @@ wait_for = ["//:i18n:format-fix"] [tasks."install:ios"] description = "Install CocoaPods dependencies" hide = true -sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugin-dependencies"] +sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugins-dependencies"] outputs = ["ios/Pods/Manifest.lock"] run = ''' echo "Running pod install" diff --git a/mobile/scripts/checkout_ios.sh b/mobile/scripts/checkout_ios.sh new file mode 100644 index 00000000000000..74706e0d23e6c8 --- /dev/null +++ b/mobile/scripts/checkout_ios.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Reconciles the iOS project with the current Flutter setup +# +# `flutter build ios --config-only` is relatively expensive, so we attempt to avoid running it when possible + +set -euo pipefail + +stamp="build/ios_checkout.stamp" + +# All tracked files that can invalidate the Xcode + Flutter build state +inputs_digest() { + git ls-files -z ios pubspec.yaml pubspec.lock mise.toml mise.lock \ + | xargs -0 shasum \ + | shasum \ + | cut -d ' ' -f 1 +} + +# Echoes why the iOS project needs reconciling, or returns 1 if it does not. +# The first three cases can happen with no tracked file having changed at all, +# so comparing the digest alone is not enough. +staleness() { + local flutter_root + flutter_root="$(sed -n 's/^FLUTTER_ROOT=//p' ios/Flutter/Generated.xcconfig 2>/dev/null || true)" + + if [[ -z $flutter_root ]]; then + echo "misconfigured FLUTTER_ROOT" + elif [[ ! -d $flutter_root ]]; then + echo "the Flutter SDK has changed" + elif ! cmp -s ios/Podfile.lock ios/Pods/Manifest.lock; then + # This is also done by Xcode's "Check Pods Manifest.lock" build phase + echo "Cocoapods out of sync" + elif [[ ! -f $stamp ]]; then + echo "first checkout" + elif [[ $(<"$stamp") != "$(inputs_digest)" ]]; then + echo "tracked build state has changed" + else + return 1 + fi +} + +[[ $(uname) == Darwin ]] || exit 0 + +if ! reason="$(staleness)"; then + exit 0 +fi + +echo "Reconciling iOS project: $reason" +flutter build ios --config-only --debug + +# Save tracked files manifest +mkdir -p "$(dirname "$stamp")" +inputs_digest > "$stamp" diff --git a/mobile/test/unit/presentation/actions/delete_action_test.dart b/mobile/test/unit/presentation/actions/delete_action_test.dart index 29d81a5c2c61c8..4c1bf9306c52e5 100644 --- a/mobile/test/unit/presentation/actions/delete_action_test.dart +++ b/mobile/test/unit/presentation/actions/delete_action_test.dart @@ -170,6 +170,17 @@ void main() { verifyNever(() => assetService.trash(any())); verifyNever(() => assetService.delete(any())); }); + + testWidgets('is labelled trash', (tester) async { + await tester.pumpTestWidget( + context, + const ActionButton(action: DeleteAction(source: .timeline)), + overrides: context.selected({LocalAssetFactory.create()}), + ); + + expect(find.text(StaticTranslations.instance.trash), findsOneWidget); + expect(find.text(StaticTranslations.instance.delete), findsNothing); + }); }); group('prompt handling', () { diff --git a/open-api/patch/pubspec_immich_mobile.yaml.patch b/open-api/patch/pubspec_immich_mobile.yaml.patch index ad3bbc2edd363b..be210f864f6c26 100644 --- a/open-api/patch/pubspec_immich_mobile.yaml.patch +++ b/open-api/patch/pubspec_immich_mobile.yaml.patch @@ -6,4 +6,4 @@ -dev_dependencies: - test: '>=1.21.6 <1.22.0' + immich_mobile: -+ path: ../ ++ path: ../../ diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index bcc74c654dcd2a..cd32bf6023ce65 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 2c11b7d3cb22a8..949acaf1396342 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/web/eslint.config.js b/web/eslint.config.js index 5974eaf68f5af5..fc69385c93774a 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -10,10 +10,6 @@ import parser from 'svelte-eslint-parser'; import typescriptEslint from 'typescript-eslint'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); export default typescriptEslint.config( ...eslintPluginSvelte.configs.recommended, @@ -29,7 +25,7 @@ export default typescriptEslint.config( 'error', { browserslist: fs - .readFileSync(path.join(__dirname, '.browserslistrc'), 'utf8') + .readFileSync(path.join(import.meta.dirname, '.browserslistrc'), 'utf8') .split('\n') .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')), @@ -40,7 +36,7 @@ export default typescriptEslint.config( parser, parserOptions: { project: ['./tsconfig.json'], - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, // ignores: ['**/service-worker/**'], @@ -96,7 +92,7 @@ export default typescriptEslint.config( parserOptions: { extraFileExtensions: ['.svelte'], - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, project: ['./tsconfig.json'], }, }, diff --git a/web/vite.config.ts b/web/vite.config.ts index c2b2b5bf253305..07e7efaf2a8e86 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -28,8 +28,8 @@ export default defineConfig({ alias: { 'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js', // eslint-disable-next-line unicorn/prefer-module - '@test-data': path.resolve(__dirname, './src/test-data'), - // '@immich/ui': path.resolve(__dirname, '../../ui/packages/ui'), + '@test-data': path.resolve(import.meta.dirname, './src/test-data'), + // '@immich/ui': path.resolve(import.meta.dirname, '../../ui/packages/ui'), }, }, server: {