Preliminary face recognition app support

This commit is contained in:
Ming Ming 2021-09-08 18:44:14 +08:00
parent 2c8434b768
commit 33887402ec
16 changed files with 1256 additions and 5 deletions

View file

@ -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<Response> 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);

View file

@ -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<String> exchangePassword(Account account) async {
final response = await Api(account).request(

View file

@ -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";
}

113
lib/bloc/list_person.dart Normal file
View file

@ -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<Person> items;
}
class ListPersonBlocInit extends ListPersonBlocState {
ListPersonBlocInit() : super(null, const []);
}
class ListPersonBlocLoading extends ListPersonBlocState {
const ListPersonBlocLoading(Account? account, List<Person> items)
: super(account, items);
}
class ListPersonBlocSuccess extends ListPersonBlocState {
const ListPersonBlocSuccess(Account? account, List<Person> items)
: super(account, items);
}
class ListPersonBlocFailure extends ListPersonBlocState {
const ListPersonBlocFailure(
Account? account, List<Person> 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<ListPersonBlocEvent, ListPersonBlocState> {
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>("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<ListPersonBloc>(bloc, name: "ListPersonBloc($id)");
return bloc;
}
}
@override
mapEventToState(ListPersonBlocEvent event) async* {
_log.info("[mapEventToState] $event");
if (event is ListPersonBlocQuery) {
yield* _onEventQuery(event);
}
}
Stream<ListPersonBlocState> _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<List<Person>> _query(ListPersonBlocQuery ev) {
final personRepo = PersonRepo(PersonRemoteDataSource());
return personRepo.list(ev.account);
}
static final _log = Logger("bloc.list_personListPersonBloc");
}

51
lib/entity/person.dart Normal file
View file

@ -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<Face> faces;
}
class PersonRepo {
const PersonRepo(this.dataSrc);
/// See [PersonDataSource.list]
Future<List<Person>> list(Account account) => this.dataSrc.list(account);
final PersonDataSource dataSrc;
}
abstract class PersonDataSource {
/// List all people for this account
Future<List<Person>> list(Account account);
}

View file

@ -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<JsonObj> dataJson = json["ocs"]["data"].cast<JsonObj>();
return _PersonParser().parseList(dataJson);
}
static final _log =
Logger("entity.person.data_source.PersonRemoteDataSource");
}
class _PersonParser {
List<Person> parseList(List<JsonObj> jsons) {
final product = <Person>[];
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<JsonObj>()
.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");
}

View file

@ -1 +1,2 @@
const mainUrl = "https://gitlab.com/nkming2/nc-photos/-/wikis/home";
const peopleUrl = "https://gitlab.com/nkming2/nc-photos/-/wikis/help/people";

View file

@ -10,6 +10,7 @@ class Lab {
}
bool get enableSharedAlbum => Pref.inst().isLabEnableSharedAlbumOr(false);
bool get enablePeople => Pref.inst().isLabEnablePeopleOr(false);
Lab._();

View file

@ -93,6 +93,11 @@ class Pref {
Future<bool> setLabEnableSharedAlbum(bool value) =>
_pref.setBool("isLabEnableSharedAlbum", value);
bool? isLabEnablePeople() => _pref.getBool("isLabEnablePeople");
bool isLabEnablePeopleOr(bool def) => isLabEnablePeople() ?? def;
Future<bool> setLabEnablePeople(bool value) =>
_pref.setBool("isLabEnablePeople", value);
Pref._();
static final _inst = Pref._();

View file

@ -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<List<File>> 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 = <File>[];
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<File> _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<String, dynamic>());
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<String, dynamic>());
return dbEntry.file;
}
static final _log = Logger("use_case.populate_album.PopulatePerson");
}

View file

@ -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) {

View file

@ -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<HomeAlbums>
);
}
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<HomeAlbums>
}
}).map((e) => e.item2);
itemStreamListItems = [
if (Lab().enablePeople) _buildPersonItem(context),
_buildArchiveItem(context),
_buildTrashbinItem(context),
if (Lab().enableSharedAlbum) _buildShareItem(context),

View file

@ -63,6 +63,13 @@ class _LabSettingsState extends State<LabSettings> {
Pref.inst().setLabEnableSharedAlbum(value);
},
),
_LabBoolItem(
title: Text("enablePeople"),
isSelected: Pref.inst().isLabEnablePeopleOr(false),
onChanged: (value) {
Pref.inst().setLabEnablePeople(value);
},
),
],
);
}

View file

@ -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<MyApp> 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<MyApp> implements SnackBarHandler {
return null;
}
Route<dynamic>? _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<dynamic>? _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<ScaffoldMessengerState>();
late AppEventListener<ThemeChangedEvent> _themeChangedListener;

View file

@ -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<PeopleBrowser> {
@override
initState() {
super.initState();
_initBloc();
}
@override
build(BuildContext context) {
return AppTheme(
child: Scaffold(
body: BlocListener<ListPersonBloc, ListPersonBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<ListPersonBloc, ListPersonBlocState>(
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<Person> 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;
}

View file

@ -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<PersonBrowser>
with SelectableItemStreamListMixin<PersonBrowser> {
@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<void> _onAddToAlbumPressed(BuildContext context) async {
try {
final value = await showDialog<Album>(
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<void> _onArchivePressed(BuildContext context) async {
final selectedFiles =
selectedListItems.whereType<_ListItem>().map((e) => e.file).toList();
setState(() {
clearSelectedItems();
});
final fileRepo = FileRepo(FileCachedDataSource());
await NotifiedListAction<File>(
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<void> _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<File>(
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<File> 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<File>? _backingFiles;
var _thumbZoomLevel = 0;
late final Throttler _refreshThrottler = Throttler(
onTriggered: (_) {
_initPerson();
},
logTag: "_PersonBrowserState.refresh",
);
late final _filePropertyUpdatedListener =
AppEventListener<FilePropertyUpdatedEvent>(_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,
}