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 | 189 ++++++++++++++++++ .../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, 375 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 4beb2f7010..9b36e360b8 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 d855502ef0..b32ce5f493 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 1d08ccf130..4f38ba868e 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 0000000000..a856a00140 --- /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 0000000000..449d5b6c8c --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/asset_people.provider.g.dart @@ -0,0 +1,189 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'asset_people.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$assetPeopleNotifierHash() => + r'192a4ee188f781000fe43f1675c49e1081ccc631'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$AssetPeopleNotifier extends BuildlessAutoDisposeAsyncNotifier< + List> { + late final Asset asset; + + Future> build( + Asset asset, + ); +} + +/// Maintains the list of people for an asset. +/// +/// Copied from [AssetPeopleNotifier]. +@ProviderFor(AssetPeopleNotifier) +const assetPeopleNotifierProvider = AssetPeopleNotifierFamily(); + +/// Maintains the list of people for an asset. +/// +/// Copied from [AssetPeopleNotifier]. +class AssetPeopleNotifierFamily + extends Family>> { + /// Maintains the list of people for an asset. + /// + /// Copied from [AssetPeopleNotifier]. + const AssetPeopleNotifierFamily(); + + /// Maintains the list of people for an asset. + /// + /// Copied from [AssetPeopleNotifier]. + AssetPeopleNotifierProvider call( + Asset asset, + ) { + return AssetPeopleNotifierProvider( + asset, + ); + } + + @override + AssetPeopleNotifierProvider getProviderOverride( + covariant AssetPeopleNotifierProvider provider, + ) { + return call( + provider.asset, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'assetPeopleNotifierProvider'; +} + +/// Maintains the list of people for an asset. +/// +/// Copied from [AssetPeopleNotifier]. +class AssetPeopleNotifierProvider extends AutoDisposeAsyncNotifierProviderImpl< + AssetPeopleNotifier, List> { + /// Maintains the list of people for an asset. + /// + /// Copied from [AssetPeopleNotifier]. + AssetPeopleNotifierProvider( + Asset asset, + ) : this._internal( + () => AssetPeopleNotifier()..asset = asset, + from: assetPeopleNotifierProvider, + name: r'assetPeopleNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$assetPeopleNotifierHash, + dependencies: AssetPeopleNotifierFamily._dependencies, + allTransitiveDependencies: + AssetPeopleNotifierFamily._allTransitiveDependencies, + asset: asset, + ); + + AssetPeopleNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.asset, + }) : super.internal(); + + final Asset asset; + + @override + Future> runNotifierBuild( + covariant AssetPeopleNotifier notifier, + ) { + return notifier.build( + asset, + ); + } + + @override + Override overrideWith(AssetPeopleNotifier Function() create) { + return ProviderOverride( + origin: this, + override: AssetPeopleNotifierProvider._internal( + () => create()..asset = asset, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + asset: asset, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement> createElement() { + return _AssetPeopleNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is AssetPeopleNotifierProvider && other.asset == asset; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, asset.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin AssetPeopleNotifierRef + on AutoDisposeAsyncNotifierProviderRef> { + /// The parameter `asset` of this provider. + Asset get asset; +} + +class _AssetPeopleNotifierProviderElement + extends AutoDisposeAsyncNotifierProviderElement> with AssetPeopleNotifierRef { + _AssetPeopleNotifierProviderElement(super.provider); + + @override + Asset get asset => (origin as AssetPeopleNotifierProvider).asset; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member 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 3c6d5f2b6c..c84e857eef 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 c556adbec2..09225a35fc 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 aa3403f2a1..f85f13e602 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 d6c556ef6d..ab114d691b 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 3086ab9246..a9a65d2632 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;