diff --git a/lib/api/api.dart b/lib/api/api.dart index 5ae2dee6..003cad44 100644 --- a/lib/api/api.dart +++ b/lib/api/api.dart @@ -405,6 +405,7 @@ class _Ocs { _Ocs(this._api); _OcsDav dav() => _OcsDav(this); + _OcsFacerecognition facerecognition() => _OcsFacerecognition(this); _OcsFilesSharing filesSharing() => _OcsFilesSharing(this); Api _api; @@ -448,6 +449,40 @@ class _OcsDavDirect { static final _log = Logger("api.api._OcsDavDirect"); } +class _OcsFacerecognition { + _OcsFacerecognition(this._ocs); + + _OcsFacerecognitionPersons persons() => _OcsFacerecognitionPersons(this); + + final _Ocs _ocs; +} + +class _OcsFacerecognitionPersons { + _OcsFacerecognitionPersons(this._facerecognition); + + Future get() async { + try { + return await _facerecognition._ocs._api.request( + "GET", + "ocs/v2.php/apps/facerecognition/api/v1/persons", + header: { + "OCS-APIRequest": "true", + }, + queryParameters: { + "format": "json", + }, + ); + } catch (e) { + _log.severe("[get] Failed while get", e); + rethrow; + } + } + + final _OcsFacerecognition _facerecognition; + + static final _log = Logger("api.api._OcsFacerecognitionPersons"); +} + class _OcsFilesSharing { _OcsFilesSharing(this._ocs); diff --git a/lib/api/api_util.dart b/lib/api/api_util.dart index e77ea694..ae4d80a3 100644 --- a/lib/api/api_util.dart +++ b/lib/api/api_util.dart @@ -4,6 +4,7 @@ import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.dart'; import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/exception.dart'; /// Return the preview image URL for [file]. See [getFilePreviewUrlRelative] @@ -67,6 +68,25 @@ String getWebdavRootUrlRelative(Account account) => String getTrashbinPath(Account account) => "remote.php/dav/trashbin/${account.username}/trash"; +/// Return the face image URL. See [getFacePreviewUrlRelative] +String getFacePreviewUrl( + Account account, + Face face, { + required int size, +}) { + return "${account.url}/" + "${getFacePreviewUrlRelative(account, face, size: size)}"; +} + +/// Return the relative URL of the face image +String getFacePreviewUrlRelative( + Account account, + Face face, { + required int size, +}) { + return "index.php/apps/facerecognition/face/${face.id}/thumb/$size"; +} + /// Query the app password for [account] Future exchangePassword(Account account) async { final response = await Api(account).request( diff --git a/lib/app_db.dart b/lib/app_db.dart index 161a0408..fe34811a 100644 --- a/lib/app_db.dart +++ b/lib/app_db.dart @@ -164,7 +164,7 @@ class AppDbFileDbEntry { AppDbFileDbEntry(this.namespacedFileId, this.file); factory AppDbFileDbEntry.fromFile(Account account, File file) { - return AppDbFileDbEntry(toNamespacedFileId(account, file), file); + return AppDbFileDbEntry(toNamespacedFileId(account, file.fileId!), file); } JsonObj toJson() { @@ -188,6 +188,6 @@ class AppDbFileDbEntry { static String toPrimaryKey(Account account, File file) => "${account.url}/${file.path}"; - static String toNamespacedFileId(Account account, File file) => - "${account.url}/${file.fileId}"; + static String toNamespacedFileId(Account account, int fileId) => + "${account.url}/$fileId"; } diff --git a/lib/bloc/list_person.dart b/lib/bloc/list_person.dart new file mode 100644 index 00000000..73605337 --- /dev/null +++ b/lib/bloc/list_person.dart @@ -0,0 +1,113 @@ +import 'package:bloc/bloc.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/person.dart'; +import 'package:nc_photos/entity/person/data_source.dart'; + +abstract class ListPersonBlocEvent { + const ListPersonBlocEvent(); +} + +class ListPersonBlocQuery extends ListPersonBlocEvent { + const ListPersonBlocQuery(this.account); + + @override + toString() { + return "$runtimeType {" + "account: $account, " + "}"; + } + + final Account account; +} + +abstract class ListPersonBlocState { + const ListPersonBlocState(this.account, this.items); + + @override + toString() { + return "$runtimeType {" + "account: $account, " + "items: List {length: ${items.length}}, " + "}"; + } + + final Account? account; + final List items; +} + +class ListPersonBlocInit extends ListPersonBlocState { + ListPersonBlocInit() : super(null, const []); +} + +class ListPersonBlocLoading extends ListPersonBlocState { + const ListPersonBlocLoading(Account? account, List items) + : super(account, items); +} + +class ListPersonBlocSuccess extends ListPersonBlocState { + const ListPersonBlocSuccess(Account? account, List items) + : super(account, items); +} + +class ListPersonBlocFailure extends ListPersonBlocState { + const ListPersonBlocFailure( + Account? account, List items, this.exception) + : super(account, items); + + @override + toString() { + return "$runtimeType {" + "super: ${super.toString()}, " + "exception: $exception, " + "}"; + } + + final dynamic exception; +} + +/// List all people recognized in an account +class ListPersonBloc extends Bloc { + ListPersonBloc() : super(ListPersonBlocInit()); + + static ListPersonBloc of(Account account) { + final id = "${account.scheme}://${account.username}@${account.address}"; + try { + _log.fine("[of] Resolving bloc for '$id'"); + return KiwiContainer().resolve("ListPersonBloc($id)"); + } catch (_) { + // no created instance for this account, make a new one + _log.info("[of] New bloc instance for account: $account"); + final bloc = ListPersonBloc(); + KiwiContainer() + .registerInstance(bloc, name: "ListPersonBloc($id)"); + return bloc; + } + } + + @override + mapEventToState(ListPersonBlocEvent event) async* { + _log.info("[mapEventToState] $event"); + if (event is ListPersonBlocQuery) { + yield* _onEventQuery(event); + } + } + + Stream _onEventQuery(ListPersonBlocQuery ev) async* { + try { + yield ListPersonBlocLoading(ev.account, state.items); + yield ListPersonBlocSuccess(ev.account, await _query(ev)); + } catch (e, stackTrace) { + _log.severe("[_onEventQuery] Exception while request", e, stackTrace); + yield ListPersonBlocFailure(ev.account, state.items, e); + } + } + + Future> _query(ListPersonBlocQuery ev) { + final personRepo = PersonRepo(PersonRemoteDataSource()); + return personRepo.list(ev.account); + } + + static final _log = Logger("bloc.list_personListPersonBloc"); +} diff --git a/lib/entity/person.dart b/lib/entity/person.dart new file mode 100644 index 00000000..d33e118c --- /dev/null +++ b/lib/entity/person.dart @@ -0,0 +1,51 @@ +import 'package:equatable/equatable.dart'; +import 'package:nc_photos/account.dart'; + +class Face with EquatableMixin { + Face({ + required this.id, + required this.fileId, + }); + + @override + get props => [ + id, + fileId, + ]; + + final int id; + final int fileId; +} + +class Person with EquatableMixin { + Person({ + this.name, + required this.id, + required this.faces, + }); + + @override + get props => [ + name, + id, + faces, + ]; + + final String? name; + final int id; + final List faces; +} + +class PersonRepo { + const PersonRepo(this.dataSrc); + + /// See [PersonDataSource.list] + Future> list(Account account) => this.dataSrc.list(account); + + final PersonDataSource dataSrc; +} + +abstract class PersonDataSource { + /// List all people for this account + Future> list(Account account); +} diff --git a/lib/entity/person/data_source.dart b/lib/entity/person/data_source.dart new file mode 100644 index 00000000..dbd6c36e --- /dev/null +++ b/lib/entity/person/data_source.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api.dart'; +import 'package:nc_photos/entity/person.dart'; +import 'package:nc_photos/exception.dart'; +import 'package:nc_photos/type.dart'; + +class PersonRemoteDataSource implements PersonDataSource { + const PersonRemoteDataSource(); + + @override + list(Account account) async { + _log.info("[list] $account"); + final response = await Api(account).ocs().facerecognition().persons().get(); + if (!response.isGood) { + _log.severe("[list] Failed requesting server: $response"); + throw ApiException( + response: response, + message: "Failed communicating with server: ${response.statusCode}"); + } + + final json = jsonDecode(response.body); + final List dataJson = json["ocs"]["data"].cast(); + return _PersonParser().parseList(dataJson); + } + + static final _log = + Logger("entity.person.data_source.PersonRemoteDataSource"); +} + +class _PersonParser { + List parseList(List jsons) { + final product = []; + for (final j in jsons) { + try { + product.add(parseSingle(j)); + } catch (e) { + _log.severe("[parseList] Failed parsing json: ${jsonEncode(j)}", e); + } + } + return product; + } + + Person parseSingle(JsonObj json) { + final faces = (json["faces"] as List) + .cast() + .map((e) => Face( + id: e["id"], + fileId: e["file-id"], + )) + .toList(); + return Person( + name: json["name"], + id: json["id"], + faces: faces, + ); + } + + static final _log = Logger("entity.person.data_source._PersonParser"); +} diff --git a/lib/help_utils.dart b/lib/help_utils.dart index 6246336f..f05ade34 100644 --- a/lib/help_utils.dart +++ b/lib/help_utils.dart @@ -1 +1,2 @@ const mainUrl = "https://gitlab.com/nkming2/nc-photos/-/wikis/home"; +const peopleUrl = "https://gitlab.com/nkming2/nc-photos/-/wikis/help/people"; diff --git a/lib/lab.dart b/lib/lab.dart index b8f93b8e..0e46c9f3 100644 --- a/lib/lab.dart +++ b/lib/lab.dart @@ -10,6 +10,7 @@ class Lab { } bool get enableSharedAlbum => Pref.inst().isLabEnableSharedAlbumOr(false); + bool get enablePeople => Pref.inst().isLabEnablePeopleOr(false); Lab._(); diff --git a/lib/pref.dart b/lib/pref.dart index dfddd381..a30aebc4 100644 --- a/lib/pref.dart +++ b/lib/pref.dart @@ -93,6 +93,11 @@ class Pref { Future setLabEnableSharedAlbum(bool value) => _pref.setBool("isLabEnableSharedAlbum", value); + bool? isLabEnablePeople() => _pref.getBool("isLabEnablePeople"); + bool isLabEnablePeopleOr(bool def) => isLabEnablePeople() ?? def; + Future setLabEnablePeople(bool value) => + _pref.setBool("isLabEnablePeople", value); + Pref._(); static final _inst = Pref._(); diff --git a/lib/use_case/populate_person.dart b/lib/use_case/populate_person.dart new file mode 100644 index 00000000..c2ee3d3e --- /dev/null +++ b/lib/use_case/populate_person.dart @@ -0,0 +1,55 @@ +import 'package:idb_shim/idb_client.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/app_db.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/person.dart'; +import 'package:nc_photos/exception.dart'; + +class PopulatePerson { + /// Return a list of files belonging to this person + Future> call(Account account, Person person) async { + return await AppDb.use((db) async { + final transaction = + db.transaction(AppDb.fileDbStoreName, idbModeReadOnly); + final store = transaction.objectStore(AppDb.fileDbStoreName); + final index = store.index(AppDbFileDbEntry.indexName); + final products = []; + for (final f in person.faces) { + try { + products.add(await _populateOne(account, f, store, index)); + } catch (e, stackTrace) { + _log.severe("[call] Failed populating file of face: ${f.fileId}", e, + stackTrace); + } + } + return products; + }); + } + + Future _populateOne( + Account account, Face face, ObjectStore store, Index index) async { + final List dbItems = await index + .getAll(AppDbFileDbEntry.toNamespacedFileId(account, face.fileId)); + // find the one owned by us + Map? dbItem; + try { + dbItem = dbItems.firstWhere((element) { + final e = AppDbFileDbEntry.fromJson(element.cast()); + return file_util.getUserDirName(e.file) == account.username; + }); + } on StateError catch (_) { + // not found + } + if (dbItem == null) { + _log.warning( + "[_populateOne] File doesn't exist in DB, removed?: '${face.fileId}'"); + throw CacheNotFoundException(); + } + final dbEntry = AppDbFileDbEntry.fromJson(dbItem.cast()); + return dbEntry.file; + } + + static final _log = Logger("use_case.populate_album.PopulatePerson"); +} diff --git a/lib/use_case/resync_album.dart b/lib/use_case/resync_album.dart index f4fb67d6..8269c3d0 100644 --- a/lib/use_case/resync_album.dart +++ b/lib/use_case/resync_album.dart @@ -46,8 +46,8 @@ class ResyncAlbum { ObjectStore objStore, Index index) async { Map? dbItem; if (item.file.fileId != null) { - final List dbItems = await index - .getAll(AppDbFileDbEntry.toNamespacedFileId(account, item.file)); + final List dbItems = await index.getAll( + AppDbFileDbEntry.toNamespacedFileId(account, item.file.fileId!)); // find the one owned by us try { dbItem = dbItems.firstWhere((element) { diff --git a/lib/widget/home_albums.dart b/lib/widget/home_albums.dart index 00754c81..34bad03f 100644 --- a/lib/widget/home_albums.dart +++ b/lib/widget/home_albums.dart @@ -31,6 +31,7 @@ import 'package:nc_photos/widget/home_app_bar.dart'; import 'package:nc_photos/widget/new_album_dialog.dart'; import 'package:nc_photos/widget/page_visibility_mixin.dart'; import 'package:nc_photos/widget/pending_albums.dart'; +import 'package:nc_photos/widget/people_browser.dart'; import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; import 'package:nc_photos/widget/selection_app_bar.dart'; import 'package:nc_photos/widget/trashbin_browser.dart'; @@ -186,6 +187,19 @@ class _HomeAlbumsState extends State ); } + SelectableItem _buildPersonItem(BuildContext context) { + return _ButtonListItem( + icon: Icons.person_outlined, + label: "People", + onTap: () { + if (!isSelectionMode) { + Navigator.of(context).pushNamed(PeopleBrowser.routeName, + arguments: PeopleBrowserArguments(widget.account)); + } + }, + ); + } + SelectableItem _buildArchiveItem(BuildContext context) { return _ButtonListItem( icon: Icons.archive_outlined, @@ -356,6 +370,7 @@ class _HomeAlbumsState extends State } }).map((e) => e.item2); itemStreamListItems = [ + if (Lab().enablePeople) _buildPersonItem(context), _buildArchiveItem(context), _buildTrashbinItem(context), if (Lab().enableSharedAlbum) _buildShareItem(context), diff --git a/lib/widget/lab_settings.dart b/lib/widget/lab_settings.dart index a35e3b11..cf76e10b 100644 --- a/lib/widget/lab_settings.dart +++ b/lib/widget/lab_settings.dart @@ -63,6 +63,13 @@ class _LabSettingsState extends State { Pref.inst().setLabEnableSharedAlbum(value); }, ), + _LabBoolItem( + title: Text("enablePeople"), + isSelected: Pref.inst().isLabEnablePeopleOr(false), + onChanged: (value) { + Pref.inst().setLabEnablePeople(value); + }, + ), ], ); } diff --git a/lib/widget/my_app.dart b/lib/widget/my_app.dart index 576a6ab3..30c7f6b7 100644 --- a/lib/widget/my_app.dart +++ b/lib/widget/my_app.dart @@ -16,6 +16,8 @@ import 'package:nc_photos/widget/dynamic_album_browser.dart'; import 'package:nc_photos/widget/home.dart'; import 'package:nc_photos/widget/lab_settings.dart'; import 'package:nc_photos/widget/pending_albums.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'; import 'package:nc_photos/widget/setup.dart'; @@ -129,6 +131,8 @@ class _MyAppState extends State implements SnackBarHandler { route ??= _handleTrashbinBrowserRoute(settings); route ??= _handleTrashbinViewerRoute(settings); route ??= _handlePendingAlbumsRoute(settings); + route ??= _handlePeopleBrowserRoute(settings); + route ??= _handlePersonBrowserRoute(settings); return route; } @@ -320,6 +324,32 @@ class _MyAppState extends State implements SnackBarHandler { 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; + } + + Route? _handlePersonBrowserRoute(RouteSettings settings) { + try { + if (settings.name == PersonBrowser.routeName && + settings.arguments != null) { + final args = settings.arguments as PersonBrowserArguments; + return PersonBrowser.buildRoute(args); + } + } catch (e) { + _log.severe("[_handlePersonBrowserRoute] Failed while handling route", e); + } + return null; + } + final _scaffoldMessengerKey = GlobalKey(); late AppEventListener _themeChangedListener; diff --git a/lib/widget/people_browser.dart b/lib/widget/people_browser.dart new file mode 100644 index 00000000..bdac27e1 --- /dev/null +++ b/lib/widget/people_browser.dart @@ -0,0 +1,328 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.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/entity/person.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/help_utils.dart' as help_utils; +import 'package:nc_photos/iterable_extension.dart'; +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/empty_list_indicator.dart'; +import 'package:nc_photos/widget/person_browser.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class PeopleBrowserArguments { + 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), + ); + + 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() { + _bloc = ListPersonBloc.of(widget.account); + if (_bloc.state is ListPersonBlocInit) { + _log.info("[_initBloc] Initialize bloc"); + _reqQuery(); + } else { + // process the current state + WidgetsBinding.instance!.addPostFrameCallback((_) { + setState(() { + _onStateChange(context, _bloc.state); + }); + }); + } + } + + Widget _buildContent(BuildContext context, ListPersonBlocState state) { + if (state is ListPersonBlocSuccess && _items.isEmpty) { + return Column( + children: [ + AppBar( + title: Text("People"), + elevation: 0, + actions: [ + Stack( + fit: StackFit.passthrough, + children: [ + IconButton( + onPressed: () { + launch(help_utils.peopleUrl); + }, + icon: const Icon(Icons.help_outline), + tooltip: L10n.global().helpTooltip, + ), + Positioned.directional( + textDirection: Directionality.of(context), + end: 0, + top: 0, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 10), + child: Icon( + Icons.circle, + color: Colors.red, + size: 8, + ), + ), + ), + ], + ), + ], + ), + Expanded( + child: EmptyListIndicator( + icon: Icons.person_outlined, + text: L10n.global().listEmptyText, + ), + ), + ], + ); + } else { + return Stack( + children: [ + Theme( + data: Theme.of(context).copyWith( + accentColor: AppTheme.getOverscrollIndicatorColor(context), + ), + child: CustomScrollView( + slivers: [ + _buildAppBar(context), + SliverPadding( + padding: const EdgeInsets.only(top: 8), + sliver: SliverStaggeredGrid.extentBuilder( + maxCrossAxisExtent: 192, + itemCount: _items.length, + itemBuilder: _buildItem, + staggeredTileBuilder: (index) => + const StaggeredTile.count(1, 1), + ), + ), + ], + ), + ), + if (state is ListPersonBlocLoading) + Align( + alignment: Alignment.bottomCenter, + child: const LinearProgressIndicator(), + ), + ], + ); + } + } + + Widget _buildAppBar(BuildContext context) { + return SliverAppBar( + title: Text("People"), + 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); + 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 + .where((element) => element.name != null) + .sorted((a, b) => a.name!.compareTo(b.name!)) + .map((e) => _PersonListItem( + account: widget.account, + name: e.name!, + faceUrl: api_util.getFacePreviewUrl(widget.account, e.faces.first, + size: 256), + onTap: () => _onItemTap(e), + )) + .toList(); + // _items = []; + } + + void _reqQuery() { + _bloc.add(ListPersonBlocQuery(widget.account)); + } + + late ListPersonBloc _bloc; + + 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(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + 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( + 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/lib/widget/person_browser.dart b/lib/widget/person_browser.dart new file mode 100644 index 00000000..b9b79951 --- /dev/null +++ b/lib/widget/person_browser.dart @@ -0,0 +1,528 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.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/debug_util.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/item.dart'; +import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/entity/person.dart'; +import 'package:nc_photos/event/event.dart'; +import 'package:nc_photos/iterable_extension.dart'; +import 'package:nc_photos/notified_action.dart'; +import 'package:nc_photos/platform/k.dart' as platform_k; +import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/share_handler.dart'; +import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/throttler.dart'; +import 'package:nc_photos/use_case/add_to_album.dart'; +import 'package:nc_photos/use_case/populate_person.dart'; +import 'package:nc_photos/use_case/remove.dart'; +import 'package:nc_photos/use_case/update_property.dart'; +import 'package:nc_photos/widget/album_picker_dialog.dart'; +import 'package:nc_photos/widget/photo_list_item.dart'; +import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart'; +import 'package:nc_photos/widget/selection_app_bar.dart'; +import 'package:nc_photos/widget/viewer.dart'; +import 'package:nc_photos/widget/zoom_menu_button.dart'; + +class PersonBrowserArguments { + PersonBrowserArguments(this.account, this.person); + + final Account account; + final Person person; +} + +/// Show a list of all faces associated with this person +class PersonBrowser extends StatefulWidget { + static const routeName = "/person-browser"; + + static Route buildRoute(PersonBrowserArguments args) => MaterialPageRoute( + builder: (context) => PersonBrowser.fromArgs(args), + ); + + PersonBrowser({ + Key? key, + required this.account, + required this.person, + }) : super(key: key); + + PersonBrowser.fromArgs(PersonBrowserArguments args, {Key? key}) + : this( + key: key, + account: args.account, + person: args.person, + ); + + @override + createState() => _PersonBrowserState(); + + final Account account; + final Person person; +} + +class _PersonBrowserState extends State + with SelectableItemStreamListMixin { + @override + initState() { + super.initState(); + _initPerson(); + _thumbZoomLevel = Pref.inst().getAlbumBrowserZoomLevelOr(0); + + _filePropertyUpdatedListener.begin(); + } + + @override + dispose() { + _filePropertyUpdatedListener.end(); + super.dispose(); + } + + @override + build(BuildContext context) { + return AppTheme( + child: Scaffold( + body: Builder( + builder: (context) => _buildContent(context), + ), + ), + ); + } + + void _initPerson() async { + final items = await PopulatePerson()(widget.account, widget.person); + if (mounted) { + setState(() { + _transformItems(items); + }); + } + } + + Widget _buildContent(BuildContext context) { + if (_backingFiles == null) { + return CustomScrollView( + slivers: [ + _buildNormalAppBar(context), + const SliverToBoxAdapter( + child: const LinearProgressIndicator(), + ), + ], + ); + } else { + return buildItemStreamListOuter( + context, + child: Theme( + data: Theme.of(context).copyWith( + accentColor: AppTheme.getOverscrollIndicatorColor(context), + ), + child: CustomScrollView( + slivers: [ + _buildAppBar(context), + buildItemStreamList( + maxCrossAxisExtent: _thumbSize.toDouble(), + ), + ], + ), + ), + ); + } + } + + Widget _buildAppBar(BuildContext context) { + if (isSelectionMode) { + return _buildSelectionAppBar(context); + } else { + return _buildNormalAppBar(context); + } + } + + Widget _buildNormalAppBar(BuildContext context) { + return SliverAppBar( + floating: true, + titleSpacing: 0, + title: Row( + children: [ + SizedBox( + height: 40, + width: 40, + child: _buildFaceImage(context), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.person.name!, + style: TextStyle( + color: AppTheme.getPrimaryTextColor(context), + ), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.clip, + ), + if (_backingFiles != null) + Text( + "${_backingFiles!.length} photos", + style: TextStyle( + color: AppTheme.getSecondaryTextColor(context), + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + // ), + actions: [ + ZoomMenuButton( + initialZoom: _thumbZoomLevel, + minZoom: 0, + maxZoom: 2, + onZoomChanged: (value) { + setState(() { + _thumbZoomLevel = value.round(); + }); + Pref.inst().setAlbumBrowserZoomLevel(_thumbZoomLevel); + }, + ), + ], + ); + } + + Widget _buildFaceImage(BuildContext context) { + Widget cover; + try { + cover = FittedBox( + clipBehavior: Clip.hardEdge, + fit: BoxFit.cover, + child: CachedNetworkImage( + imageUrl: api_util.getFacePreviewUrl( + widget.account, widget.person.faces.first, + size: 64), + httpHeaders: { + "Authorization": Api.getAuthorizationHeaderValue(widget.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: 24, + ); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(64), + child: Container( + color: AppTheme.getListItemBackgroundColor(context), + constraints: const BoxConstraints.expand(), + child: cover, + ), + ); + } + + Widget _buildSelectionAppBar(BuildContext context) { + return SelectionAppBar( + count: selectedListItems.length, + onClosePressed: () { + setState(() { + clearSelectedItems(); + }); + }, + actions: [ + if (platform_k.isAndroid) + IconButton( + icon: const Icon(Icons.share), + tooltip: L10n.global().shareTooltip, + onPressed: () { + _onSharePressed(context); + }, + ), + IconButton( + icon: const Icon(Icons.playlist_add), + tooltip: L10n.global().addToAlbumTooltip, + onPressed: () { + _onAddToAlbumPressed(context); + }, + ), + PopupMenuButton<_SelectionMenuOption>( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + itemBuilder: (context) => [ + PopupMenuItem( + value: _SelectionMenuOption.archive, + child: Text(L10n.global().archiveTooltip), + ), + PopupMenuItem( + value: _SelectionMenuOption.delete, + child: Text(L10n.global().deleteTooltip), + ), + ], + onSelected: (option) { + _onOptionMenuSelected(context, option); + }, + ), + ], + ); + } + + void _onItemTap(int index) { + Navigator.pushNamed(context, Viewer.routeName, + arguments: ViewerArguments(widget.account, _backingFiles!, index)); + } + + void _onSharePressed(BuildContext context) { + assert(platform_k.isAndroid); + final selected = + selectedListItems.whereType<_ListItem>().map((e) => e.file).toList(); + ShareHandler().shareFiles(context, widget.account, selected).then((_) { + setState(() { + clearSelectedItems(); + }); + }); + } + + Future _onAddToAlbumPressed(BuildContext context) async { + try { + final value = await showDialog( + context: context, + builder: (_) => AlbumPickerDialog( + account: widget.account, + ), + ); + if (value == null) { + // user cancelled the dialog + return; + } + + _log.info("[_onAddToAlbumPressed] Album picked: ${value.name}"); + await NotifiedAction( + () async { + assert(value.provider is AlbumStaticProvider); + final selected = selectedListItems + .whereType<_ListItem>() + .map((e) => AlbumFileItem(file: e.file)) + .toList(); + final albumRepo = AlbumRepo(AlbumCachedDataSource()); + await AddToAlbum(albumRepo)(widget.account, value, selected); + setState(() { + clearSelectedItems(); + }); + }, + null, + L10n.global().addSelectedToAlbumSuccessNotification(value.name), + failureText: L10n.global().addSelectedToAlbumFailureNotification, + )(); + } catch (e, stackTrace) { + _log.shout("[_onAddToAlbumPressed] Exception", e, stackTrace); + } + } + + Future _onArchivePressed(BuildContext context) async { + final selectedFiles = + selectedListItems.whereType<_ListItem>().map((e) => e.file).toList(); + setState(() { + clearSelectedItems(); + }); + final fileRepo = FileRepo(FileCachedDataSource()); + await NotifiedListAction( + list: selectedFiles, + action: (file) async { + await UpdateProperty(fileRepo) + .updateIsArchived(widget.account, file, true); + }, + processingText: L10n.global() + .archiveSelectedProcessingNotification(selectedFiles.length), + successText: L10n.global().archiveSelectedSuccessNotification, + getFailureText: (failures) => + L10n.global().archiveSelectedFailureNotification(failures.length), + onActionError: (file, e, stackTrace) { + _log.shout( + "[_onArchivePressed] Failed while archiving file" + + (shouldLogFileName ? ": ${file.path}" : ""), + e, + stackTrace); + }, + )(); + } + + Future _onDeletePressed(BuildContext context) async { + final selectedFiles = + selectedListItems.whereType<_ListItem>().map((e) => e.file).toList(); + setState(() { + clearSelectedItems(); + }); + final fileRepo = FileRepo(FileCachedDataSource()); + final albumRepo = AlbumRepo(AlbumCachedDataSource()); + await NotifiedListAction( + list: selectedFiles, + action: (file) async { + await Remove(fileRepo, albumRepo)(widget.account, file); + }, + processingText: L10n.global() + .deleteSelectedProcessingNotification(selectedFiles.length), + successText: L10n.global().deleteSelectedSuccessNotification, + getFailureText: (failures) => + L10n.global().deleteSelectedFailureNotification(failures.length), + onActionError: (file, e, stackTrace) { + _log.shout( + "[_onDeletePressed] Failed while removing file" + + (shouldLogFileName ? ": ${file.path}" : ""), + e, + stackTrace); + }, + )(); + } + + void _onOptionMenuSelected( + BuildContext context, _SelectionMenuOption option) { + switch (option) { + case _SelectionMenuOption.archive: + _onArchivePressed(context); + break; + + case _SelectionMenuOption.delete: + _onDeletePressed(context); + break; + + default: + _log.shout("[_onOptionMenuSelected] Unknown option: $option"); + break; + } + } + + void _onFilePropertyUpdated(FilePropertyUpdatedEvent ev) { + if (_backingFiles?.containsIf(ev.file, (a, b) => a.fileId == b.fileId) != + true) { + return; + } + _refreshThrottler.trigger( + maxResponceTime: const Duration(seconds: 3), + maxPendingCount: 10, + ); + } + + void _transformItems(List items) { + _backingFiles = items + .sorted(compareFileDateTimeDescending) + .where((element) => + file_util.isSupportedFormat(element) && element.isArchived != true) + .toList(); + itemStreamListItems = _backingFiles! + .mapWithIndex((i, f) => _ListItem( + index: i, + file: f, + account: widget.account, + previewUrl: api_util.getFilePreviewUrl( + widget.account, + f, + width: _thumbSize, + height: _thumbSize, + ), + onTap: () => _onItemTap(i), + )) + .toList(); + } + + int get _thumbSize { + switch (_thumbZoomLevel) { + case 1: + return 176; + + case 2: + return 256; + + case 0: + default: + return 112; + } + } + + List? _backingFiles; + + var _thumbZoomLevel = 0; + + late final Throttler _refreshThrottler = Throttler( + onTriggered: (_) { + _initPerson(); + }, + logTag: "_PersonBrowserState.refresh", + ); + + late final _filePropertyUpdatedListener = + AppEventListener(_onFilePropertyUpdated); + + static final _log = Logger("widget.person_browser._PersonBrowserState"); +} + +class _ListItem implements SelectableItem { + _ListItem({ + required this.index, + required this.file, + required this.account, + required this.previewUrl, + VoidCallback? onTap, + }) : _onTap = onTap; + + @override + get onTap => _onTap; + + @override + get isSelectable => true; + + @override + get staggeredTile => const StaggeredTile.count(1, 1); + + @override + operator ==(Object other) { + return other is _ListItem && file.path == other.file.path; + } + + @override + get hashCode => file.path.hashCode; + + @override + toString() { + return "$runtimeType {" + "index: $index, " + "}"; + } + + @override + buildWidget(BuildContext context) { + return PhotoListImage( + account: account, + previewUrl: previewUrl, + isGif: file.contentType == "image/gif", + ); + } + + final int index; + final File file; + final Account account; + final String previewUrl; + final VoidCallback? _onTap; +} + +enum _SelectionMenuOption { + archive, + delete, +}