mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-23 01:06:21 +01:00
Preliminary face recognition app support
This commit is contained in:
parent
2c8434b768
commit
33887402ec
16 changed files with 1256 additions and 5 deletions
|
@ -405,6 +405,7 @@ class _Ocs {
|
||||||
_Ocs(this._api);
|
_Ocs(this._api);
|
||||||
|
|
||||||
_OcsDav dav() => _OcsDav(this);
|
_OcsDav dav() => _OcsDav(this);
|
||||||
|
_OcsFacerecognition facerecognition() => _OcsFacerecognition(this);
|
||||||
_OcsFilesSharing filesSharing() => _OcsFilesSharing(this);
|
_OcsFilesSharing filesSharing() => _OcsFilesSharing(this);
|
||||||
|
|
||||||
Api _api;
|
Api _api;
|
||||||
|
@ -448,6 +449,40 @@ class _OcsDavDirect {
|
||||||
static final _log = Logger("api.api._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 {
|
class _OcsFilesSharing {
|
||||||
_OcsFilesSharing(this._ocs);
|
_OcsFilesSharing(this._ocs);
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:nc_photos/account.dart';
|
||||||
import 'package:nc_photos/api/api.dart';
|
import 'package:nc_photos/api/api.dart';
|
||||||
import 'package:nc_photos/entity/file.dart';
|
import 'package:nc_photos/entity/file.dart';
|
||||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/entity/person.dart';
|
||||||
import 'package:nc_photos/exception.dart';
|
import 'package:nc_photos/exception.dart';
|
||||||
|
|
||||||
/// Return the preview image URL for [file]. See [getFilePreviewUrlRelative]
|
/// Return the preview image URL for [file]. See [getFilePreviewUrlRelative]
|
||||||
|
@ -67,6 +68,25 @@ String getWebdavRootUrlRelative(Account account) =>
|
||||||
String getTrashbinPath(Account account) =>
|
String getTrashbinPath(Account account) =>
|
||||||
"remote.php/dav/trashbin/${account.username}/trash";
|
"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]
|
/// Query the app password for [account]
|
||||||
Future<String> exchangePassword(Account account) async {
|
Future<String> exchangePassword(Account account) async {
|
||||||
final response = await Api(account).request(
|
final response = await Api(account).request(
|
||||||
|
|
|
@ -164,7 +164,7 @@ class AppDbFileDbEntry {
|
||||||
AppDbFileDbEntry(this.namespacedFileId, this.file);
|
AppDbFileDbEntry(this.namespacedFileId, this.file);
|
||||||
|
|
||||||
factory AppDbFileDbEntry.fromFile(Account account, File file) {
|
factory AppDbFileDbEntry.fromFile(Account account, File file) {
|
||||||
return AppDbFileDbEntry(toNamespacedFileId(account, file), file);
|
return AppDbFileDbEntry(toNamespacedFileId(account, file.fileId!), file);
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonObj toJson() {
|
JsonObj toJson() {
|
||||||
|
@ -188,6 +188,6 @@ class AppDbFileDbEntry {
|
||||||
static String toPrimaryKey(Account account, File file) =>
|
static String toPrimaryKey(Account account, File file) =>
|
||||||
"${account.url}/${file.path}";
|
"${account.url}/${file.path}";
|
||||||
|
|
||||||
static String toNamespacedFileId(Account account, File file) =>
|
static String toNamespacedFileId(Account account, int fileId) =>
|
||||||
"${account.url}/${file.fileId}";
|
"${account.url}/$fileId";
|
||||||
}
|
}
|
||||||
|
|
113
lib/bloc/list_person.dart
Normal file
113
lib/bloc/list_person.dart
Normal 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
51
lib/entity/person.dart
Normal 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);
|
||||||
|
}
|
62
lib/entity/person/data_source.dart
Normal file
62
lib/entity/person/data_source.dart
Normal 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");
|
||||||
|
}
|
|
@ -1 +1,2 @@
|
||||||
const mainUrl = "https://gitlab.com/nkming2/nc-photos/-/wikis/home";
|
const mainUrl = "https://gitlab.com/nkming2/nc-photos/-/wikis/home";
|
||||||
|
const peopleUrl = "https://gitlab.com/nkming2/nc-photos/-/wikis/help/people";
|
||||||
|
|
|
@ -10,6 +10,7 @@ class Lab {
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get enableSharedAlbum => Pref.inst().isLabEnableSharedAlbumOr(false);
|
bool get enableSharedAlbum => Pref.inst().isLabEnableSharedAlbumOr(false);
|
||||||
|
bool get enablePeople => Pref.inst().isLabEnablePeopleOr(false);
|
||||||
|
|
||||||
Lab._();
|
Lab._();
|
||||||
|
|
||||||
|
|
|
@ -93,6 +93,11 @@ class Pref {
|
||||||
Future<bool> setLabEnableSharedAlbum(bool value) =>
|
Future<bool> setLabEnableSharedAlbum(bool value) =>
|
||||||
_pref.setBool("isLabEnableSharedAlbum", 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._();
|
Pref._();
|
||||||
|
|
||||||
static final _inst = Pref._();
|
static final _inst = Pref._();
|
||||||
|
|
55
lib/use_case/populate_person.dart
Normal file
55
lib/use_case/populate_person.dart
Normal 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");
|
||||||
|
}
|
|
@ -46,8 +46,8 @@ class ResyncAlbum {
|
||||||
ObjectStore objStore, Index index) async {
|
ObjectStore objStore, Index index) async {
|
||||||
Map? dbItem;
|
Map? dbItem;
|
||||||
if (item.file.fileId != null) {
|
if (item.file.fileId != null) {
|
||||||
final List dbItems = await index
|
final List dbItems = await index.getAll(
|
||||||
.getAll(AppDbFileDbEntry.toNamespacedFileId(account, item.file));
|
AppDbFileDbEntry.toNamespacedFileId(account, item.file.fileId!));
|
||||||
// find the one owned by us
|
// find the one owned by us
|
||||||
try {
|
try {
|
||||||
dbItem = dbItems.firstWhere((element) {
|
dbItem = dbItems.firstWhere((element) {
|
||||||
|
|
|
@ -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/new_album_dialog.dart';
|
||||||
import 'package:nc_photos/widget/page_visibility_mixin.dart';
|
import 'package:nc_photos/widget/page_visibility_mixin.dart';
|
||||||
import 'package:nc_photos/widget/pending_albums.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/selectable_item_stream_list_mixin.dart';
|
||||||
import 'package:nc_photos/widget/selection_app_bar.dart';
|
import 'package:nc_photos/widget/selection_app_bar.dart';
|
||||||
import 'package:nc_photos/widget/trashbin_browser.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) {
|
SelectableItem _buildArchiveItem(BuildContext context) {
|
||||||
return _ButtonListItem(
|
return _ButtonListItem(
|
||||||
icon: Icons.archive_outlined,
|
icon: Icons.archive_outlined,
|
||||||
|
@ -356,6 +370,7 @@ class _HomeAlbumsState extends State<HomeAlbums>
|
||||||
}
|
}
|
||||||
}).map((e) => e.item2);
|
}).map((e) => e.item2);
|
||||||
itemStreamListItems = [
|
itemStreamListItems = [
|
||||||
|
if (Lab().enablePeople) _buildPersonItem(context),
|
||||||
_buildArchiveItem(context),
|
_buildArchiveItem(context),
|
||||||
_buildTrashbinItem(context),
|
_buildTrashbinItem(context),
|
||||||
if (Lab().enableSharedAlbum) _buildShareItem(context),
|
if (Lab().enableSharedAlbum) _buildShareItem(context),
|
||||||
|
|
|
@ -63,6 +63,13 @@ class _LabSettingsState extends State<LabSettings> {
|
||||||
Pref.inst().setLabEnableSharedAlbum(value);
|
Pref.inst().setLabEnableSharedAlbum(value);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
_LabBoolItem(
|
||||||
|
title: Text("enablePeople"),
|
||||||
|
isSelected: Pref.inst().isLabEnablePeopleOr(false),
|
||||||
|
onChanged: (value) {
|
||||||
|
Pref.inst().setLabEnablePeople(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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/home.dart';
|
||||||
import 'package:nc_photos/widget/lab_settings.dart';
|
import 'package:nc_photos/widget/lab_settings.dart';
|
||||||
import 'package:nc_photos/widget/pending_albums.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/root_picker.dart';
|
||||||
import 'package:nc_photos/widget/settings.dart';
|
import 'package:nc_photos/widget/settings.dart';
|
||||||
import 'package:nc_photos/widget/setup.dart';
|
import 'package:nc_photos/widget/setup.dart';
|
||||||
|
@ -129,6 +131,8 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
|
||||||
route ??= _handleTrashbinBrowserRoute(settings);
|
route ??= _handleTrashbinBrowserRoute(settings);
|
||||||
route ??= _handleTrashbinViewerRoute(settings);
|
route ??= _handleTrashbinViewerRoute(settings);
|
||||||
route ??= _handlePendingAlbumsRoute(settings);
|
route ??= _handlePendingAlbumsRoute(settings);
|
||||||
|
route ??= _handlePeopleBrowserRoute(settings);
|
||||||
|
route ??= _handlePersonBrowserRoute(settings);
|
||||||
return route;
|
return route;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -320,6 +324,32 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
|
||||||
return null;
|
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>();
|
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||||
|
|
||||||
late AppEventListener<ThemeChangedEvent> _themeChangedListener;
|
late AppEventListener<ThemeChangedEvent> _themeChangedListener;
|
||||||
|
|
328
lib/widget/people_browser.dart
Normal file
328
lib/widget/people_browser.dart
Normal 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;
|
||||||
|
}
|
528
lib/widget/person_browser.dart
Normal file
528
lib/widget/person_browser.dart
Normal 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,
|
||||||
|
}
|
Loading…
Reference in a new issue