diff --git a/mobile/apps/photos/lib/app.dart b/mobile/apps/photos/lib/app.dart index bb04f072d3..3ff91b4af8 100644 --- a/mobile/apps/photos/lib/app.dart +++ b/mobile/apps/photos/lib/app.dart @@ -15,6 +15,7 @@ import "package:photos/events/memories_changed_event.dart"; import "package:photos/events/people_changed_event.dart"; import "package:photos/generated/l10n.dart"; import "package:photos/l10n/l10n.dart"; +import "package:photos/models/collection/smart_album_config.dart"; import "package:photos/service_locator.dart"; import 'package:photos/services/app_lifecycle_service.dart'; import "package:photos/services/home_widget_service.dart"; @@ -74,8 +75,10 @@ class _EnteAppState extends State with WidgetsBindingObserver { _peopleChangedSubscription = Bus.instance.on().listen( (event) async { _changeCallbackDebouncer.run( - () async => - unawaited(PeopleHomeWidgetService.instance.checkPeopleChanged()), + () async { + unawaited(PeopleHomeWidgetService.instance.checkPeopleChanged()); + unawaited(syncSmartAlbums()); + }, ); }, ); diff --git a/mobile/apps/photos/lib/models/collection/smart_album_config.dart b/mobile/apps/photos/lib/models/collection/smart_album_config.dart new file mode 100644 index 0000000000..461f7869c6 --- /dev/null +++ b/mobile/apps/photos/lib/models/collection/smart_album_config.dart @@ -0,0 +1,165 @@ +import "package:photos/models/api/entity/type.dart"; +import "package:photos/service_locator.dart" show entityService, prefs; +import "package:photos/services/collections_service.dart"; +import "package:photos/services/search_service.dart"; +import "package:photos/ui/actions/collection/collection_file_actions.dart"; +import "package:photos/ui/actions/collection/collection_sharing_actions.dart"; +import "package:shared_preferences/shared_preferences.dart"; +import "package:synchronized/synchronized.dart"; + +class SmartAlbumConfig { + final int collectionId; + // person ids + final Set personIDs; + // person id mapped with updatedat, file ids + final Map)> addedFiles; + + SmartAlbumConfig({ + required this.collectionId, + required this.personIDs, + required this.addedFiles, + }); + + static const _personIdsKey = "smart_album_person_ids"; + static const _addedFilesKey = "smart_album_added_files"; + + Future getUpdatedConfig(Set newPersonsIds) async { + final toAdd = newPersonsIds.difference(personIDs); + final toRemove = personIDs.difference(newPersonsIds); + final newFiles = Map)>.from(addedFiles); + + // Remove whats not needed + for (final personId in toRemove) { + newFiles.remove(personId); + } + + // Add files which are needed + for (final personId in toAdd) { + newFiles[personId] = (0, {}); + } + + return SmartAlbumConfig( + collectionId: collectionId, + personIDs: newPersonsIds, + addedFiles: newFiles, + ); + } + + Future addFiles( + String personId, + int updatedAt, + Set fileId, + ) async { + if (!addedFiles.containsKey(personId)) { + return this; + } + + final newFiles = Map)>.from(addedFiles); + newFiles[personId] = ( + updatedAt, + newFiles[personId]!.$2.union(fileId), + ); + return SmartAlbumConfig( + collectionId: collectionId, + personIDs: personIDs, + addedFiles: newFiles, + ); + } + + Future saveConfig() async { + final prefs = await SharedPreferences.getInstance(); + + await prefs.setStringList( + "${_personIdsKey}_$collectionId", + personIDs.toList(), + ); + + await prefs.setString( + "${_addedFilesKey}_$collectionId", + addedFiles.entries + .map((e) => "${e.key}:${e.value.$1}|${e.value.$2.join(',')}") + .join(';'), + ); + } + + static Future loadConfig(int collectionId) async { + final personIDs = + prefs.getStringList("${_personIdsKey}_$collectionId") ?? []; + final addedFilesString = + prefs.getString("${_addedFilesKey}_$collectionId") ?? ""; + + final addedFiles = )>{}; + if (addedFilesString.isNotEmpty) { + for (final entry in addedFilesString.split(';')) { + final parts = entry.split(':'); + if (parts.length == 2) { + addedFiles[parts[0]] = ( + int.parse(parts[1].split('|')[0]), + parts[1].split('|')[1].split(',').map(int.parse).toSet(), + ); + } + } + } + + return SmartAlbumConfig( + collectionId: collectionId, + personIDs: personIDs.toSet(), + addedFiles: addedFiles, + ); + } +} + +final _lock = Lock(); + +Future syncSmartAlbums() async { + await _lock.synchronized(() async { + // get all collections + final collections = CollectionsService.instance.nonHiddenOwnedCollections(); + for (final collectionId in collections) { + final config = await SmartAlbumConfig.loadConfig(collectionId); + + if (config.personIDs.isEmpty) { + continue; + } + + for (final personId in config.personIDs) { + final person = + await entityService.getEntity(EntityType.cgroup, personId); + + if (person == null || + config.addedFiles[personId]?.$1 == null || + (person.updatedAt <= config.addedFiles[personId]!.$1)) { + continue; + } + + final files = + (await SearchService.instance.getClusterFilesForPersonID(personId)) + .entries + .expand((e) => e.value) + .toSet(); + + final toBeSynced = + files.difference(config.addedFiles[personId]?.$2 ?? {}); + + if (toBeSynced.isNotEmpty) { + final CollectionActions collectionActions = + CollectionActions(CollectionsService.instance); + final result = await collectionActions.addToCollection( + null, + collectionId, + false, + selectedFiles: toBeSynced.toList(), + ); + if (result) { + final newConfig = await config.addFiles( + personId, + person.updatedAt, + toBeSynced.map((e) => e.uploadedFileID!).toSet(), + ); + await newConfig.saveConfig(); + } + } + } + } + }); +} diff --git a/mobile/apps/photos/lib/service_locator.dart b/mobile/apps/photos/lib/service_locator.dart index bc96877eb2..35b1e31309 100644 --- a/mobile/apps/photos/lib/service_locator.dart +++ b/mobile/apps/photos/lib/service_locator.dart @@ -45,6 +45,10 @@ class ServiceLocator { } } +SharedPreferences get prefs { + return ServiceLocator.instance.prefs; +} + FlagService? _flagService; FlagService get flagService { diff --git a/mobile/apps/photos/lib/services/sync/sync_service.dart b/mobile/apps/photos/lib/services/sync/sync_service.dart index ac1ee5bb45..040cb08bb1 100644 --- a/mobile/apps/photos/lib/services/sync/sync_service.dart +++ b/mobile/apps/photos/lib/services/sync/sync_service.dart @@ -12,6 +12,7 @@ import 'package:photos/core/event_bus.dart'; import 'package:photos/events/subscription_purchased_event.dart'; import 'package:photos/events/sync_status_update_event.dart'; import 'package:photos/events/trigger_logout_event.dart'; +import "package:photos/models/collection/smart_album_config.dart"; import 'package:photos/models/file/file_type.dart'; import "package:photos/services/language_service.dart"; import 'package:photos/services/notification_service.dart'; @@ -199,6 +200,7 @@ class SyncService { if (shouldSync) { await _remoteSyncService.sync(); } + await syncSmartAlbums(); } } diff --git a/mobile/apps/photos/lib/ui/actions/collection/collection_file_actions.dart b/mobile/apps/photos/lib/ui/actions/collection/collection_file_actions.dart index 2deb10cf2a..14846dac5e 100644 --- a/mobile/apps/photos/lib/ui/actions/collection/collection_file_actions.dart +++ b/mobile/apps/photos/lib/ui/actions/collection/collection_file_actions.dart @@ -183,14 +183,14 @@ extension CollectionFileActions on CollectionActions { } Future addToCollection( - BuildContext context, + BuildContext? context, int collectionID, bool showProgressDialog, { List? selectedFiles, List? sharedFiles, List? picketAssets, }) async { - ProgressDialog? dialog = showProgressDialog + ProgressDialog? dialog = showProgressDialog && context != null ? createProgressDialog( context, S.of(context).uploadingFilesToAlbum, @@ -246,7 +246,7 @@ extension CollectionFileActions on CollectionActions { final Collection? c = CollectionsService.instance.getCollectionByID(collectionID); if (c != null && c.owner.id != currentUserID) { - if (!showProgressDialog) { + if (!showProgressDialog && context != null) { dialog = createProgressDialog( context, S.of(context).uploadingFilesToAlbum, @@ -291,7 +291,9 @@ extension CollectionFileActions on CollectionActions { } catch (e, s) { logger.severe("Failed to add to album", e, s); await dialog?.hide(); - await showGenericErrorDialog(context: context, error: e); + if (context != null) { + await showGenericErrorDialog(context: context, error: e); + } rethrow; } } diff --git a/mobile/apps/photos/lib/ui/collections/album/smart_album_people.dart b/mobile/apps/photos/lib/ui/collections/album/smart_album_people.dart new file mode 100644 index 0000000000..9225e559d7 --- /dev/null +++ b/mobile/apps/photos/lib/ui/collections/album/smart_album_people.dart @@ -0,0 +1,128 @@ +import "dart:async"; + +import 'package:flutter/material.dart'; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/collection/smart_album_config.dart"; +import "package:photos/models/selected_people.dart"; +import "package:photos/ui/components/buttons/button_widget.dart"; +import 'package:photos/ui/components/buttons/icon_button_widget.dart'; +import "package:photos/ui/components/models/button_type.dart"; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; +import "package:photos/ui/viewer/search/result/people_section_all_page.dart" + show PeopleSectionAllWidget; + +class SmartAlbumPeople extends StatefulWidget { + const SmartAlbumPeople({ + super.key, + required this.collectionId, + }); + + final int collectionId; + + @override + State createState() => _SmartAlbumPeopleState(); +} + +class _SmartAlbumPeopleState extends State { + final _selectedPeople = SelectedPeople(); + late SmartAlbumConfig currentConfig; + bool isLoading = false; + + @override + void initState() { + super.initState(); + getSelections(); + } + + Future getSelections() async { + currentConfig = await SmartAlbumConfig.loadConfig(widget.collectionId); + + _selectedPeople.select(currentConfig.personIDs); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + bottomNavigationBar: Padding( + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + 8 + MediaQuery.viewPaddingOf(context).bottom, + ), + child: ListenableBuilder( + listenable: _selectedPeople, + builder: (context, _) { + return ButtonWidget( + buttonType: ButtonType.primary, + buttonSize: ButtonSize.large, + labelText: S.of(context).save, + shouldSurfaceExecutionStates: false, + isDisabled: _selectedPeople.personIds.isEmpty, + onTap: _selectedPeople.personIds.isEmpty + ? null + : () async { + isLoading = true; + if (mounted) setState(() {}); + try { + final newConfig = await currentConfig.getUpdatedConfig( + _selectedPeople.personIds, + ); + await newConfig.saveConfig(); + syncSmartAlbums().ignore(); + Navigator.pop(context); + } catch (_) { + isLoading = false; + if (mounted) setState(() {}); + } + }, + ); + }, + ), + ), + body: Stack( + children: [ + CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: S.of(context).people, + ), + expandedHeight: MediaQuery.textScalerOf(context).scale(120), + flexibleSpaceCaption: S.of(context).peopleWidgetDesc, + actionIcons: [ + IconButtonWidget( + icon: Icons.close_outlined, + iconButtonType: IconButtonType.secondary, + onTap: () { + Navigator.pop(context); + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ], + ), + SliverFillRemaining( + child: PeopleSectionAllWidget( + selectedPeople: _selectedPeople, + namedOnly: true, + ), + ), + ], + ), + if (isLoading) + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + ), + child: const Center( + child: CircularProgressIndicator(), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/apps/photos/lib/ui/viewer/gallery/gallery_app_bar_widget.dart b/mobile/apps/photos/lib/ui/viewer/gallery/gallery_app_bar_widget.dart index d8f3a4a362..a1c116242a 100644 --- a/mobile/apps/photos/lib/ui/viewer/gallery/gallery_app_bar_widget.dart +++ b/mobile/apps/photos/lib/ui/viewer/gallery/gallery_app_bar_widget.dart @@ -32,6 +32,7 @@ import "package:photos/theme/colors.dart"; import 'package:photos/ui/actions/collection/collection_sharing_actions.dart'; import "package:photos/ui/cast/auto.dart"; import "package:photos/ui/cast/choose.dart"; +import "package:photos/ui/collections/album/smart_album_people.dart"; import "package:photos/ui/common/popup_item.dart"; import "package:photos/ui/common/web_page.dart"; import 'package:photos/ui/components/action_sheet_widget.dart'; @@ -399,6 +400,26 @@ class _GalleryAppBarWidgetState extends State { ), ); } + + if (widget.collection != null) { + actions.add( + Tooltip( + message: S.of(context).goToSettings, + child: IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () async { + await routeToPage( + context, + SmartAlbumPeople( + collectionId: widget.collection!.id, + ), + ); + }, + ), + ), + ); + } + if (galleryType.isSharable() && !widget.isFromCollectPhotos) { actions.add( Tooltip(