feat: init smart albums concept

This commit is contained in:
Prateek Sunal
2025-07-14 18:05:58 +05:30
parent 99d84a1154
commit 3708a347f5
7 changed files with 331 additions and 6 deletions

View File

@@ -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<EnteApp> with WidgetsBindingObserver {
_peopleChangedSubscription = Bus.instance.on<PeopleChangedEvent>().listen(
(event) async {
_changeCallbackDebouncer.run(
() async =>
unawaited(PeopleHomeWidgetService.instance.checkPeopleChanged()),
() async {
unawaited(PeopleHomeWidgetService.instance.checkPeopleChanged());
unawaited(syncSmartAlbums());
},
);
},
);

View File

@@ -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<String> personIDs;
// person id mapped with updatedat, file ids
final Map<String, (int, Set<int>)> 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<SmartAlbumConfig> getUpdatedConfig(Set<String> newPersonsIds) async {
final toAdd = newPersonsIds.difference(personIDs);
final toRemove = personIDs.difference(newPersonsIds);
final newFiles = Map<String, (int, Set<int>)>.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<SmartAlbumConfig> addFiles(
String personId,
int updatedAt,
Set<int> fileId,
) async {
if (!addedFiles.containsKey(personId)) {
return this;
}
final newFiles = Map<String, (int, Set<int>)>.from(addedFiles);
newFiles[personId] = (
updatedAt,
newFiles[personId]!.$2.union(fileId),
);
return SmartAlbumConfig(
collectionId: collectionId,
personIDs: personIDs,
addedFiles: newFiles,
);
}
Future<void> 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<SmartAlbumConfig> loadConfig(int collectionId) async {
final personIDs =
prefs.getStringList("${_personIdsKey}_$collectionId") ?? [];
final addedFilesString =
prefs.getString("${_addedFilesKey}_$collectionId") ?? "";
final addedFiles = <String, (int, Set<int>)>{};
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<void> 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();
}
}
}
}
});
}

View File

@@ -45,6 +45,10 @@ class ServiceLocator {
}
}
SharedPreferences get prefs {
return ServiceLocator.instance.prefs;
}
FlagService? _flagService;
FlagService get flagService {

View File

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

View File

@@ -183,14 +183,14 @@ extension CollectionFileActions on CollectionActions {
}
Future<bool> addToCollection(
BuildContext context,
BuildContext? context,
int collectionID,
bool showProgressDialog, {
List<EnteFile>? selectedFiles,
List<SharedMediaFile>? sharedFiles,
List<AssetEntity>? 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;
}
}

View File

@@ -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<SmartAlbumPeople> createState() => _SmartAlbumPeopleState();
}
class _SmartAlbumPeopleState extends State<SmartAlbumPeople> {
final _selectedPeople = SelectedPeople();
late SmartAlbumConfig currentConfig;
bool isLoading = false;
@override
void initState() {
super.initState();
getSelections();
}
Future<void> 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: <Widget>[
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(),
),
),
],
),
);
}
}

View File

@@ -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<GalleryAppBarWidget> {
),
);
}
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(