From ba12d92af36a4cb622032e4ffe5863a5dcc838fd Mon Sep 17 00:00:00 2001 From: Emanuel Bennici Date: Wed, 6 Mar 2024 17:15:54 +0100 Subject: [PATCH] feat(mobile): Add people list to exit bottom sheet (#6717) * feat(mobile): Define constants as 'const' * feat(mobile): Add people list to asset bottom sheet Add a list of people per asset in the exif bottom sheet, like on the web. Currently the list of people is loaded by making a request each time to the server. This is the MVP approach. In the future, the people information can be synced like we're doing with the assets. * styling --------- Co-authored-by: Alex Tran --- mobile/assets/i18n/de-DE.json | 3 +- mobile/assets/i18n/en-US.json | 3 +- mobile/assets/i18n/it-IT.json | 3 +- .../providers/asset_people.provider.dart | 51 +++++++++++ .../providers/asset_people.provider.g.dart | Bin 0 -> 5448 bytes .../asset_viewer/ui/exif_bottom_sheet.dart | 86 ++++++++++++++++++ .../asset_viewer/views/gallery_viewer.dart | 6 +- .../modules/search/ui/curated_people_row.dart | 4 - .../lib/modules/search/views/search_page.dart | 32 ++++--- mobile/lib/shared/services/asset.service.dart | 21 +++++ 10 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 mobile/lib/modules/asset_viewer/providers/asset_people.provider.dart create mode 100644 mobile/lib/modules/asset_viewer/providers/asset_people.provider.g.dart 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 0000000000000000000000000000000000000000..449d5b6c8c7e6f1fa6ea747898b80e65d3bd54f4 GIT binary patch literal 5448 zcmc&&ZExE)5dQ98aUY6C-ZI%<+9Y+Iv}v5Q#nz-qf(^y6sj_@dILM+$QeJ|g|9y9) z=tzqEQY^&`v}i@%+jH;ibl~mD*~!Jx)yWW!&xa@Q0}RjM?EDHooDbiheu6ij;N8*L zu+^Hy%7C1}R;)FeXohl@VmMQBF2<Y+IVsC$cvbW#scDoaNwlnGN z?mbVQy}({~zn3J*?oQ7k(Sc(*6F7#6l2aHmfS1FcT1iThAiBKM2B$0m{vr0I|A$e~ zCMW^4iH%4=WCjvB9f=GB{`U>hY}>E%IL)zb=>Z5Vah*wpeIRGWQ#7a|5=9@Um>F$t zU|KkU?rm@4e()3m`|*2l++Nyy|Cw13bZ-fuJ(fZ;SVS8J1L$?zWSoT)O|CL~0)G@r z93H~1J^oU&e!92{`ZXe7CU<1&p60-+zLU}XYD6dD_zr#U$g2kZHaSfRiQE%Df< z4}=d{*CCKU#N=E0qru!Dq@){H8&_svA{E4$6CLt(OH$6R-x>hCTBX&^j}ry{+xdHU_Ncg|ia-2&>J?i3nWKmWpIVZC3RgTGRJt}`8y z%w!ul0H|^?z)OuNO5L~CqCx(`!AYE^&Cyv@R^=+y3LcIeTpV#=+t3EEW$d-27N)38 z()SfS;F>bJ2-ow1!tqVyJXRv6(!Yv)R?ynKs%n03A%MRY#+=A?U5l3Eoc5kNNIXh$ zuux06Fgtt&(HLh`Z7@p&YRZu;Pg7eJ%GR!8K-NN)$6=Lexdm>Z4>|El#hDg{CWhf7 zD7S*4n+K?AFTL{s3O8oFVkbjJ&6$P@8Yrv}n(GeD5Z|;kE-fzJ*^7R%td01K@29iW zwUmbN+=li2f&0w=SOAvfexEv#&|yS<2^IA}pW~d|b|`Gh#-JU##G;faB^c9yodf%; zdX39QcbS8vza~V=_s2NOuiv6MR!A-TQsyebcQkeNZ8dT{`EQp>BPr96=hBmGE|jzr zVz9+id^n%-SU=7av(>KAGW{!?>J10|}5kxE>P%oQJpIxK`W z=8I=kk2_9QSEro(6{U$i+_&Y)O9b1b`Zm|^zm=n~ezr6K>lc?6s+uqxUbXaVJ6-Hr z^>DUYvhH|*PEzDWm}cY$yOTF@eXnv@7OV(f?p^dW;3mYVfptxKz^T)$Ki6cCR4n+J z0}c*?^U64^|QK&285T%TyIG?_Ws5V*O{T0y{EbDsDyA1i%GO zEF(#$Yhr?~u3wZa7CfL_(Q3MQTY=9uLO=7K%1*tXtVy^H1PiFWWM{czcV8bPR(ZeJ zt+QZ*sD=$@u)nE}Ey2>7ze#Aj9yTlX5ONe5Qm*wiPSO+-pVlA+i;NGXCFPjx=x3z3{e8siklk#z*WpwWw$7)QCbhrV@1Q=E=aJvg5W REod3Cat&%juZG3d*1stw@v;B_ literal 0 HcmV?d00001 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;