mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
feat: merge people on mobile
This commit is contained in:
@@ -31,4 +31,25 @@ class DriftPeopleService {
|
||||
await _personApiRepository.update(personId, birthday: birthday);
|
||||
return _repository.updateBirthday(personId, birthday);
|
||||
}
|
||||
|
||||
Future<({int merged, int failed})> mergePerson(String targetPersonId, List<String> personIdsToMerge) async {
|
||||
if (personIdsToMerge.isEmpty) {
|
||||
return (merged: 0, failed: 0);
|
||||
}
|
||||
|
||||
final mergedIds = await _personApiRepository.mergePerson(targetPersonId, personIdsToMerge);
|
||||
final failed = personIdsToMerge.length - mergedIds.length;
|
||||
|
||||
if (mergedIds.isNotEmpty) {
|
||||
final updatedTarget = await _personApiRepository.getById(targetPersonId);
|
||||
await _repository.mergePeople(
|
||||
targetPersonId,
|
||||
mergedIds,
|
||||
name: updatedTarget.name,
|
||||
birthDate: updatedTarget.birthDate,
|
||||
);
|
||||
}
|
||||
|
||||
return (merged: mergedIds.length, failed: failed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
@@ -73,6 +74,29 @@ class DriftPeopleRepository extends DriftDatabaseRepository {
|
||||
|
||||
return query.write(PersonEntityCompanion(birthDate: Value(birthday), updatedAt: Value(DateTime.now())));
|
||||
}
|
||||
|
||||
Future<void> mergePeople(
|
||||
String targetPersonId,
|
||||
List<String> mergedPersonIds, {
|
||||
required String name,
|
||||
DateTime? birthDate,
|
||||
}) {
|
||||
if (mergedPersonIds.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
return _db.transaction(() async {
|
||||
await (_db.update(_db.assetFaceEntity)..where((face) => face.personId.isIn(mergedPersonIds))).write(
|
||||
AssetFaceEntityCompanion(personId: Value(targetPersonId)),
|
||||
);
|
||||
|
||||
await _db.personEntity.deleteWhere((row) => row.id.isIn(mergedPersonIds));
|
||||
|
||||
await (_db.update(_db.personEntity)..where((row) => row.id.equals(targetPersonId))).write(
|
||||
PersonEntityCompanion(name: Value(name), birthDate: Value(birthDate), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension on PersonEntityData {
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/people/person_option_sheet.wi
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/people.utils.dart';
|
||||
import 'package:immich_mobile/widgets/common/person_sliver_app_bar.dart';
|
||||
|
||||
@@ -49,6 +50,16 @@ class _DriftPersonPageState extends ConsumerState<DriftPersonPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleMerge() async {
|
||||
final updatedPerson = await context.pushRoute<DriftPerson?>(DriftPersonMergeRoute(person: _person));
|
||||
|
||||
if (updatedPerson != null) {
|
||||
setState(() {
|
||||
_person = updatedPerson;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void showOptionSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -64,6 +75,10 @@ class _DriftPersonPageState extends ConsumerState<DriftPersonPage> {
|
||||
await handleEditBirthday(context);
|
||||
ContextHelper(context).pop();
|
||||
},
|
||||
onMerge: () {
|
||||
ContextHelper(context).pop();
|
||||
handleMerge();
|
||||
},
|
||||
birthdayExists: _person.birthDate != null,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
const _kMaxMergeSelection = 5;
|
||||
|
||||
@RoutePage()
|
||||
class DriftPersonMergePage extends ConsumerStatefulWidget {
|
||||
final DriftPerson person;
|
||||
|
||||
const DriftPersonMergePage({super.key, required this.person});
|
||||
|
||||
@override
|
||||
ConsumerState<DriftPersonMergePage> createState() => _DriftPersonMergePageState();
|
||||
}
|
||||
|
||||
class _DriftPersonMergePageState extends ConsumerState<DriftPersonMergePage> {
|
||||
final Set<String> _selectedIds = {};
|
||||
bool _isMerging = false;
|
||||
|
||||
void _toggleSelection(String personId) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(personId)) {
|
||||
_selectedIds.remove(personId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedIds.length >= _kMaxMergeSelection) {
|
||||
ref.read(toastRepositoryProvider).info(context.t.merge_people_limit);
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedIds.add(personId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _confirmMerge() async {
|
||||
if (_selectedIds.isEmpty || _isMerging) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.t.merge_people, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
content: Text(context.t.merge_people_prompt),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => ContextHelper(context).pop(false),
|
||||
child: Text(
|
||||
context.t.cancel,
|
||||
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => ContextHelper(context).pop(true),
|
||||
child: Text(
|
||||
context.t.merge_people,
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed != true || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isMerging = true);
|
||||
|
||||
try {
|
||||
final result = await ref.read(driftPeopleServiceProvider).mergePerson(widget.person.id, _selectedIds.toList());
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.merged > 0) {
|
||||
ref.invalidate(driftGetAllPeopleProvider);
|
||||
|
||||
ref.read(toastRepositoryProvider).success(context.t.merged_people_count(count: result.merged));
|
||||
|
||||
final updatedPerson = await ref.read(driftPeopleServiceProvider).get(widget.person.id);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ContextHelper(context).pop(updatedPerson ?? widget.person);
|
||||
} else {
|
||||
ref.read(toastRepositoryProvider).error(context.t.cannot_merge_people);
|
||||
}
|
||||
} catch (error) {
|
||||
dPrint(() => 'Error merging people: $error');
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(toastRepositoryProvider).error(context.t.scaffold_body_error_occurred);
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
setState(() => _isMerging = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final people = ref.watch(driftGetAllPeopleProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.t.merge_people),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _selectedIds.isEmpty || _isMerging ? null : _confirmMerge,
|
||||
child: _isMerging
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text(context.t.merge_people),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: people.when(
|
||||
data: (allPeople) {
|
||||
final candidates = allPeople.where((person) => person.id != widget.person.id).toList();
|
||||
|
||||
return GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, childAspectRatio: 0.85),
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
itemCount: candidates.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = candidates[index];
|
||||
final isSelected = _selectedIds.contains(person.id);
|
||||
|
||||
return GestureDetector(
|
||||
key: ValueKey(person.id),
|
||||
onTap: () => _toggleSelection(person.id),
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.bottomRight,
|
||||
children: [
|
||||
Material(
|
||||
shape: CircleBorder(
|
||||
side: BorderSide(color: isSelected ? context.primaryColor : Colors.transparent, width: 3),
|
||||
),
|
||||
elevation: 3,
|
||||
child: CircleAvatar(
|
||||
maxRadius: 48,
|
||||
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: context.primaryColor,
|
||||
child: const Icon(Icons.check, size: 16, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
person.name.isEmpty ? context.t.add_a_name : person.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (error, stack) => const Center(child: Text('error')),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
|
||||
class PersonOptionSheet extends ConsumerWidget {
|
||||
const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday, this.birthdayExists = false});
|
||||
const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday, this.onMerge, this.birthdayExists = false});
|
||||
|
||||
final VoidCallback? onEditName;
|
||||
final VoidCallback? onEditBirthday;
|
||||
final VoidCallback? onMerge;
|
||||
final bool birthdayExists;
|
||||
|
||||
@override
|
||||
@@ -21,14 +22,19 @@ class PersonOptionSheet extends ConsumerWidget {
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: Text('edit_name'.t(context: context), style: textStyle),
|
||||
title: Text(context.t.edit_name, style: textStyle),
|
||||
onTap: onEditName,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cake),
|
||||
title: Text((birthdayExists ? 'edit_birthday' : "add_birthday").t(context: context), style: textStyle),
|
||||
title: Text((birthdayExists ? context.t.edit_birthday : context.t.add_birthday), style: textStyle),
|
||||
onTap: onEditBirthday,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.merge_rounded),
|
||||
title: Text(context.t.merge_people, style: textStyle),
|
||||
onTap: onMerge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -26,6 +26,16 @@ class PersonApiRepository extends ApiRepository {
|
||||
return _toPerson(response);
|
||||
}
|
||||
|
||||
Future<PersonDto> getById(String id) async {
|
||||
final response = await checkNull(_api.getPerson(id));
|
||||
return _toPerson(response);
|
||||
}
|
||||
|
||||
Future<List<String>> mergePerson(String id, List<String> ids) async {
|
||||
final response = await checkNull(_api.mergePerson(id, MergePersonDto(ids: ids)));
|
||||
return response.where((result) => result.success).map((result) => result.id).toList();
|
||||
}
|
||||
|
||||
static PersonDto _toPerson(PersonResponseDto dto) => PersonDto(
|
||||
birthDate: dto.birthDate,
|
||||
id: dto.id,
|
||||
|
||||
@@ -56,6 +56,7 @@ import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_person_merge.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_place.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
|
||||
@@ -183,6 +184,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: SyncStatusRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]),
|
||||
AutoRoute(page: DriftPersonMergeRoute.page, guards: [_authGuard]),
|
||||
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
|
||||
@@ -885,6 +885,53 @@ class DriftPeopleCollectionRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftPersonMergePage]
|
||||
class DriftPersonMergeRoute extends PageRouteInfo<DriftPersonMergeRouteArgs> {
|
||||
DriftPersonMergeRoute({
|
||||
Key? key,
|
||||
required DriftPerson person,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftPersonMergeRoute.name,
|
||||
args: DriftPersonMergeRouteArgs(key: key, person: person),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'DriftPersonMergeRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<DriftPersonMergeRouteArgs>();
|
||||
return DriftPersonMergePage(key: args.key, person: args.person);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class DriftPersonMergeRouteArgs {
|
||||
const DriftPersonMergeRouteArgs({this.key, required this.person});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final DriftPerson person;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftPersonMergeRouteArgs{key: $key, person: $person}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! DriftPersonMergeRouteArgs) return false;
|
||||
return key == other.key && person == other.person;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode ^ person.hashCode;
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftPersonPage]
|
||||
class DriftPersonRoute extends PageRouteInfo<DriftPersonRouteArgs> {
|
||||
|
||||
Reference in New Issue
Block a user