diff --git a/mobile/assets/i18n/de-DE.json b/mobile/assets/i18n/de-DE.json index 4beb2f701..9b36e360b 100644 --- a/mobile/assets/i18n/de-DE.json +++ b/mobile/assets/i18n/de-DE.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "STANDORT", "exif_bottom_sheet_location_add": "Aufnahmeort hinzufügen", + "exif_bottom_sheet_people": "PERSONEN", "experimental_settings_new_asset_list_subtitle": "In Arbeit", "experimental_settings_new_asset_list_title": "Experimentelles Fotogitter aktivieren", "experimental_settings_subtitle": "Benutzung auf eigene Gefahr!", @@ -476,4 +477,4 @@ "viewer_remove_from_stack": "Aus Stapel entfernen", "viewer_stack_use_as_main_asset": "An Stapelanfang", "viewer_unstack": "Stapel aufheben" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index d855502ef..b32ce5f49 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "LOCATION", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", @@ -476,4 +477,4 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index 1d08ccf13..4f38ba868 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETTAGLI", "exif_bottom_sheet_location": "POSIZIONE", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PERSONE", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Attiva griglia di foto sperimentale", "experimental_settings_subtitle": "Usalo a tuo rischio!", @@ -476,4 +477,4 @@ "viewer_remove_from_stack": "Rimuovi dalla pila", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} \ No newline at end of file +} diff --git a/mobile/lib/modules/asset_viewer/providers/asset_people.provider.dart b/mobile/lib/modules/asset_viewer/providers/asset_people.provider.dart new file mode 100644 index 000000000..a856a0014 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/asset_people.provider.dart @@ -0,0 +1,51 @@ +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/services/asset.service.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'asset_people.provider.g.dart'; + +/// Maintains the list of people for an asset. +@riverpod +class AssetPeopleNotifier extends _$AssetPeopleNotifier { + final log = Logger('AssetPeopleNotifier'); + + @override + Future> build(Asset asset) async { + if (!asset.isRemote) { + return []; + } + + final list = await ref + .watch(assetServiceProvider) + .getRemotePeopleOfAsset(asset.remoteId!); + if (list == null) { + return []; + } + + // explicitly a sorted slice to make it deterministic + // named people will be at the beginning, and names are sorted + // ascendingly + list.sort((a, b) { + final aNotEmpty = a.name.isNotEmpty; + final bNotEmpty = b.name.isNotEmpty; + if (aNotEmpty && !bNotEmpty) { + return -1; + } else if (!aNotEmpty && bNotEmpty) { + return 1; + } else if (!aNotEmpty && !bNotEmpty) { + return 0; + } + + return a.name.compareTo(b.name); + }); + return list; + } + + Future refresh() async { + // invalidate the state – this way we don't have to + // duplicate the code from build. + ref.invalidateSelf(); + } +} diff --git a/mobile/lib/modules/asset_viewer/providers/asset_people.provider.g.dart b/mobile/lib/modules/asset_viewer/providers/asset_people.provider.g.dart new file mode 100644 index 000000000..449d5b6c8 Binary files /dev/null and b/mobile/lib/modules/asset_viewer/providers/asset_people.provider.g.dart differ diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index 3c6d5f2b6..c84e857ee 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -1,13 +1,21 @@ import 'dart:io'; +import 'dart:math' as math; +import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asset_extensions.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_people.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart'; +import 'package:immich_mobile/modules/search/models/curated_content.dart'; +import 'package:immich_mobile/modules/search/ui/curated_people_row.dart'; +import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/utils/selection_handlers.dart'; @@ -24,6 +32,10 @@ class ExifBottomSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final assetWithExif = ref.watch(assetDetailProvider(asset)); final exifInfo = (assetWithExif.value ?? asset).exifInfo; + final peopleProvider = + ref.watch(assetPeopleNotifierProvider(asset).notifier); + final people = ref.watch(assetPeopleNotifierProvider(asset)); + final double imageSize = math.min(context.width / 3, 150); var textColor = context.isDarkTheme ? Colors.white : Colors.black; bool hasCoordinates() => @@ -212,6 +224,72 @@ class ExifBottomSheet extends HookConsumerWidget { ); } + showPersonNameEditModel( + String personId, + String personName, + ) { + return showDialog( + context: context, + builder: (BuildContext context) { + return PersonNameEditForm(personId: personId, personName: personName); + }, + ).then((_) { + // ensure the people list is up-to-date. + peopleProvider.refresh(); + }); + } + + buildPeople() { + return people.widgetWhen( + onData: (data) { + // either the server is not reachable or this asset has no people + if (data.isEmpty) { + return Container(); + } + + final curatedPeople = + data.map((p) => CuratedContent(id: p.id, label: p.name)).toList(); + + return Column( + children: [ + Align( + alignment: Alignment.topLeft, + child: Text( + "exif_bottom_sheet_people", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + ), + SizedBox( + height: imageSize, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: CuratedPeopleRow( + content: curatedPeople, + onTap: (content, index) { + context + .pushRoute( + PersonResultRoute( + personId: content.id, + personName: content.label, + ), + ) + .then((_) => peopleProvider.refresh()); + }, + onNameTap: (person, index) => { + showPersonNameEditModel(person.id, person.label), + }, + ), + ), + ), + ], + ); + }, + ); + } + buildDate() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -350,6 +428,12 @@ class ExifBottomSheet extends HookConsumerWidget { child: buildLocation(), ), ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: buildPeople(), + ), + ), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 300), child: Padding( @@ -382,6 +466,8 @@ class ExifBottomSheet extends HookConsumerWidget { child: CircularProgressIndicator.adaptive(), ), ), + const SizedBox(height: 16), + buildPeople(), buildLocation(), SizedBox(height: hasCoordinates() ? 16.0 : 6.0), buildDetail(), diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index c556adbec..09225a35f 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -148,9 +148,9 @@ class GalleryViewerPage extends HookConsumerWidget { } void handleSwipeUpDown(DragUpdateDetails details) { - int sensitivity = 15; - int dxThreshold = 50; - double ratioThreshold = 3.0; + const int sensitivity = 15; + const int dxThreshold = 50; + const double ratioThreshold = 3.0; if (isZoomed.value) { return; diff --git a/mobile/lib/modules/search/ui/curated_people_row.dart b/mobile/lib/modules/search/ui/curated_people_row.dart index aa3403f2a..f85f13e60 100644 --- a/mobile/lib/modules/search/ui/curated_people_row.dart +++ b/mobile/lib/modules/search/ui/curated_people_row.dart @@ -44,10 +44,6 @@ class CuratedPeopleRow extends StatelessWidget { return ListView.builder( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.only( - left: 16, - top: 8, - ), itemBuilder: (context, index) { final person = content[index]; final headers = { diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index d6c556ef6..ab114d691 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -78,19 +78,25 @@ class SearchPage extends HookConsumerWidget { height: imageSize, child: curatedPeople.widgetWhen( onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (people) => CuratedPeopleRow( - content: people.take(12).toList(), - onTap: (content, index) { - context.pushRoute( - PersonResultRoute( - personId: content.id, - personName: content.label, - ), - ); - }, - onNameTap: (person, index) => { - showNameEditModel(person.id, person.label), - }, + onData: (people) => Padding( + padding: const EdgeInsets.only( + left: 16, + top: 8, + ), + child: CuratedPeopleRow( + content: people.take(12).toList(), + onTap: (content, index) { + context.pushRoute( + PersonResultRoute( + personId: content.id, + personName: content.label, + ), + ); + }, + onNameTap: (person, index) => { + showNameEditModel(person.id, person.label), + }, + ), ), ), ); diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index 3086ab924..a9a65d263 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -61,6 +61,27 @@ class AssetService { return (assetDto.map(Asset.remote).toList(), deleted.ids); } + /// Returns the list of people of the given asset id. + // If the server is not reachable `null` is returned. + Future?> getRemotePeopleOfAsset( + String remoteId, + ) async { + try { + final AssetResponseDto? dto = + await _apiService.assetApi.getAssetInfo(remoteId); + + return dto?.people; + } catch (error, stack) { + log.severe( + 'Error while getting remote asset info: ${error.toString()}', + error, + stack, + ); + + return null; + } + } + /// Returns `null` if the server state did not change, else list of assets Future?> _getRemoteAssets(User user) async { const int chunkSize = 10000;