diff --git a/app/lib/bloc/list_person.dart b/app/lib/bloc/list_person.dart index 452b0b37..6d9e3b44 100644 --- a/app/lib/bloc/list_person.dart +++ b/app/lib/bloc/list_person.dart @@ -92,7 +92,7 @@ class ListPersonBloc extends Bloc { } Future> _query(ListPersonBlocQuery ev) => - ListPerson(_c)(ev.account); + ListPerson(_c.withLocalRepo())(ev.account); final DiContainer _c; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index cba72473..967456e9 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1370,6 +1370,10 @@ "@searchFilterBubbleFavoriteFalseText": { "description": "List of active search filters shown in the result page (by favorites, false)" }, + "showAllButtonLabel": "SHOW ALL", + "@showAllButtonLabel": { + "description": "A button to show all items of a certain item group (e.g., show all recognized faces)" + }, "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index 33b22b29..13c0efcd 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -142,6 +142,7 @@ "searchFilterFavoriteLabel", "searchFilterBubbleFavoriteTrueText", "searchFilterBubbleFavoriteFalseText", + "showAllButtonLabel", "errorAlbumDowngrade" ], @@ -302,6 +303,7 @@ "searchFilterFavoriteLabel", "searchFilterBubbleFavoriteTrueText", "searchFilterBubbleFavoriteFalseText", + "showAllButtonLabel", "errorAlbumDowngrade" ], @@ -342,12 +344,14 @@ "searchFilterBubbleTypeVideoText", "searchFilterFavoriteLabel", "searchFilterBubbleFavoriteTrueText", - "searchFilterBubbleFavoriteFalseText" + "searchFilterBubbleFavoriteFalseText", + "showAllButtonLabel" ], "es": [ "settingsLanguageOptionSystemDefaultLabel", - "rootPickerSkipConfirmationDialogContent2" + "rootPickerSkipConfirmationDialogContent2", + "showAllButtonLabel" ], "fi": [ @@ -368,7 +372,8 @@ "searchFilterBubbleTypeVideoText", "searchFilterFavoriteLabel", "searchFilterBubbleFavoriteTrueText", - "searchFilterBubbleFavoriteFalseText" + "searchFilterBubbleFavoriteFalseText", + "showAllButtonLabel" ], "fr": [ @@ -433,7 +438,8 @@ "searchFilterBubbleTypeVideoText", "searchFilterFavoriteLabel", "searchFilterBubbleFavoriteTrueText", - "searchFilterBubbleFavoriteFalseText" + "searchFilterBubbleFavoriteFalseText", + "showAllButtonLabel" ], "pl": [ @@ -515,7 +521,8 @@ "searchFilterBubbleTypeVideoText", "searchFilterFavoriteLabel", "searchFilterBubbleFavoriteTrueText", - "searchFilterBubbleFavoriteFalseText" + "searchFilterBubbleFavoriteFalseText", + "showAllButtonLabel" ], "pt": [ @@ -576,7 +583,8 @@ "searchFilterBubbleTypeVideoText", "searchFilterFavoriteLabel", "searchFilterBubbleFavoriteTrueText", - "searchFilterBubbleFavoriteFalseText" + "searchFilterBubbleFavoriteFalseText", + "showAllButtonLabel" ], "ru": [ @@ -637,7 +645,8 @@ "searchFilterBubbleTypeVideoText", "searchFilterFavoriteLabel", "searchFilterBubbleFavoriteTrueText", - "searchFilterBubbleFavoriteFalseText" + "searchFilterBubbleFavoriteFalseText", + "showAllButtonLabel" ], "zh": [ @@ -698,7 +707,8 @@ "searchFilterBubbleTypeVideoText", "searchFilterFavoriteLabel", "searchFilterBubbleFavoriteTrueText", - "searchFilterBubbleFavoriteFalseText" + "searchFilterBubbleFavoriteFalseText", + "showAllButtonLabel" ], "zh_Hant": [ @@ -759,6 +769,7 @@ "searchFilterBubbleTypeVideoText", "searchFilterFavoriteLabel", "searchFilterBubbleFavoriteTrueText", - "searchFilterBubbleFavoriteFalseText" + "searchFilterBubbleFavoriteFalseText", + "showAllButtonLabel" ] } diff --git a/app/lib/widget/my_app.dart b/app/lib/widget/my_app.dart index c3ec297d..1ed7e127 100644 --- a/app/lib/widget/my_app.dart +++ b/app/lib/widget/my_app.dart @@ -21,6 +21,7 @@ import 'package:nc_photos/widget/enhanced_photo_browser.dart'; import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/image_editor.dart'; import 'package:nc_photos/widget/local_file_viewer.dart'; +import 'package:nc_photos/widget/people_browser.dart'; import 'package:nc_photos/widget/person_browser.dart'; import 'package:nc_photos/widget/root_picker.dart'; import 'package:nc_photos/widget/settings.dart'; @@ -165,6 +166,7 @@ class _MyAppState extends State route ??= _handleImageEditorRoute(settings); route ??= _handleChangelogRoute(settings); route ??= _handleTagBrowserRoute(settings); + route ??= _handlePeopleBrowserRoute(settings); return route; } @@ -555,6 +557,19 @@ class _MyAppState extends State return null; } + Route? _handlePeopleBrowserRoute(RouteSettings settings) { + try { + if (settings.name == PeopleBrowser.routeName && + settings.arguments != null) { + final args = settings.arguments as PeopleBrowserArguments; + return PeopleBrowser.buildRoute(args); + } + } catch (e) { + _log.severe("[_handlePeopleBrowserRoute] Failed while handling route", e); + } + return null; + } + final _scaffoldMessengerKey = GlobalKey(); final _navigatorKey = GlobalKey(); diff --git a/app/lib/widget/people_browser.dart b/app/lib/widget/people_browser.dart new file mode 100644 index 00000000..92da34f9 --- /dev/null +++ b/app/lib/widget/people_browser.dart @@ -0,0 +1,292 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/bloc/list_person.dart'; +import 'package:nc_photos/cache_manager_util.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/person.dart'; +import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/widget/person_browser.dart'; + +class PeopleBrowserArguments { + const PeopleBrowserArguments(this.account); + + final Account account; +} + +/// Show a list of all people associated with this account +class PeopleBrowser extends StatefulWidget { + static const routeName = "/people-browser"; + + static Route buildRoute(PeopleBrowserArguments args) => MaterialPageRoute( + builder: (context) => PeopleBrowser.fromArgs(args), + ); + + const PeopleBrowser({ + Key? key, + required this.account, + }) : super(key: key); + + PeopleBrowser.fromArgs(PeopleBrowserArguments args, {Key? key}) + : this( + key: key, + account: args.account, + ); + + @override + createState() => _PeopleBrowserState(); + + final Account account; +} + +class _PeopleBrowserState extends State { + @override + initState() { + super.initState(); + _initBloc(); + } + + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: BlocListener( + bloc: _bloc, + listener: (context, state) => _onStateChange(context, state), + child: BlocBuilder( + bloc: _bloc, + builder: (context, state) => _buildContent(context, state), + ), + ), + ), + ); + } + + void _initBloc() { + if (_bloc.state is ListPersonBlocInit) { + _log.info("[_initBloc] Initialize bloc"); + } else { + // process the current state + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _onStateChange(context, _bloc.state); + }); + }); + } + _reqQuery(); + } + + Widget _buildContent(BuildContext context, ListPersonBlocState state) { + return Stack( + children: [ + Theme( + data: Theme.of(context).copyWith( + colorScheme: Theme.of(context).colorScheme.copyWith( + secondary: AppTheme.getOverscrollIndicatorColor(context), + ), + ), + child: CustomScrollView( + slivers: [ + _buildAppBar(context), + if (state is ListPersonBlocLoading) + const SliverToBoxAdapter( + child: Align( + alignment: Alignment.center, + child: LinearProgressIndicator(), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverStaggeredGrid.extentBuilder( + maxCrossAxisExtent: 160, + mainAxisSpacing: 8, + itemCount: _items.length, + itemBuilder: _buildItem, + staggeredTileBuilder: (_) => const StaggeredTile.count(1, 1), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildAppBar(BuildContext context) { + return SliverAppBar( + title: Text(L10n.global().collectionPeopleLabel), + floating: true, + ); + } + + Widget _buildItem(BuildContext context, int index) { + final item = _items[index]; + return item.buildWidget(context); + } + + void _onStateChange(BuildContext context, ListPersonBlocState state) { + if (state is ListPersonBlocInit) { + _items = []; + } else if (state is ListPersonBlocSuccess || + state is ListPersonBlocLoading) { + _transformItems(state.items); + } else if (state is ListPersonBlocFailure) { + _transformItems(state.items); + try { + final e = state.exception as ApiException; + if (e.response.statusCode == 404) { + // face recognition app probably not installed, ignore + return; + } + } catch (_) {} + SnackBarManager().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(state.exception)), + duration: k.snackBarDurationNormal, + )); + } + } + + void _onItemTap(Person person) { + Navigator.pushNamed(context, PersonBrowser.routeName, + arguments: PersonBrowserArguments(widget.account, person)); + } + + void _transformItems(List items) { + _items = items + .sorted((a, b) => a.name.compareTo(b.name)) + .map((e) => _PersonListItem( + account: widget.account, + name: e.name, + faceUrl: api_util.getFacePreviewUrl(widget.account, e.thumbFaceId, + size: k.faceThumbSize), + onTap: () => _onItemTap(e), + )) + .toList(); + } + + void _reqQuery() { + _bloc.add(ListPersonBlocQuery(widget.account)); + } + + late final _bloc = ListPersonBloc(KiwiContainer().resolve()); + + var _items = <_ListItem>[]; + + static final _log = Logger("widget.people_browser._PeopleBrowserState"); +} + +abstract class _ListItem { + _ListItem({ + this.onTap, + }); + + Widget buildWidget(BuildContext context); + + final VoidCallback? onTap; +} + +class _PersonListItem extends _ListItem { + _PersonListItem({ + required this.account, + required this.name, + required this.faceUrl, + VoidCallback? onTap, + }) : super(onTap: onTap); + + @override + buildWidget(BuildContext context) { + final content = Padding( + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Center( + child: AspectRatio( + aspectRatio: 1, + child: _buildFaceImage(context), + ), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.center, + child: Text( + name + "\n", + style: Theme.of(context).textTheme.bodyText1!.copyWith( + color: AppTheme.getPrimaryTextColor(context), + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + if (onTap != null) { + return InkWell( + onTap: onTap, + child: content, + ); + } else { + return content; + } + } + + Widget _buildFaceImage(BuildContext context) { + Widget cover; + try { + cover = FittedBox( + clipBehavior: Clip.hardEdge, + fit: BoxFit.cover, + child: CachedNetworkImage( + cacheManager: ThumbnailCacheManager.inst, + imageUrl: faceUrl!, + httpHeaders: { + "Authorization": Api.getAuthorizationHeaderValue(account), + }, + fadeInDuration: const Duration(), + filterQuality: FilterQuality.high, + errorWidget: (context, url, error) { + // just leave it empty + return Container(); + }, + imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet, + ), + ); + } catch (_) { + cover = Icon( + Icons.person, + color: Colors.white.withOpacity(.8), + size: 64, + ); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(128), + child: Container( + color: AppTheme.getListItemBackgroundColor(context), + constraints: const BoxConstraints.expand(), + child: cover, + ), + ); + } + + final Account account; + final String name; + final String? faceUrl; +} diff --git a/app/lib/widget/search_landing.dart b/app/lib/widget/search_landing.dart index c0d61a1a..21ae392a 100644 --- a/app/lib/widget/search_landing.dart +++ b/app/lib/widget/search_landing.dart @@ -21,6 +21,7 @@ import 'package:nc_photos/pref.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; import 'package:nc_photos/url_launcher_util.dart'; +import 'package:nc_photos/widget/people_browser.dart'; import 'package:nc_photos/widget/person_browser.dart'; class SearchLanding extends StatefulWidget { @@ -110,21 +111,30 @@ class _SearchLandingState extends State { List _buildPeopleSection( BuildContext context, SearchLandingBlocState state) { + final isNoResult = (state is SearchLandingBlocSuccess || + state is SearchLandingBlocFailure) && + state.persons.isEmpty; return [ ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16), title: Text(L10n.global().collectionPeopleLabel), - trailing: IconButton( - onPressed: () { - launch(help_util.peopleUrl); - }, - tooltip: L10n.global().helpTooltip, - icon: const Icon(Icons.help_outline), - ), + trailing: isNoResult + ? IconButton( + onPressed: () { + launch(help_util.peopleUrl); + }, + tooltip: L10n.global().helpTooltip, + icon: const Icon(Icons.help_outline), + ) + : TextButton( + onPressed: () { + Navigator.of(context).pushNamed(PeopleBrowser.routeName, + arguments: PeopleBrowserArguments(widget.account)); + }, + child: Text(L10n.global().showAllButtonLabel), + ), ), - if ((state is SearchLandingBlocSuccess || - state is SearchLandingBlocFailure) && - state.persons.isEmpty) + if (isNoResult) SizedBox( height: 48, child: Center(