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);
|
||||
|
||||
_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);
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
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 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 enablePeople => Pref.inst().isLabEnablePeopleOr(false);
|
||||
|
||||
Lab._();
|
||||
|
||||
|
|
|
@ -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._();
|
||||
|
|
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 {
|
||||
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) {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
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