Add search

This commit is contained in:
Ming Ming 2022-08-06 12:21:11 +08:00
parent ad9260385b
commit 7c8dedf259
22 changed files with 3138 additions and 641 deletions

View file

@ -18,6 +18,8 @@ import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/entity/local_file/data_source.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/entity/person/data_source.dart';
import 'package:nc_photos/entity/search.dart';
import 'package:nc_photos/entity/search/data_source.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/share/data_source.dart';
import 'package:nc_photos/entity/sharee.dart';
@ -183,6 +185,7 @@ Future<void> _initDiContainer(InitIsolateType isolateType) async {
c.tagRepoRemote = const TagRepo(TagRemoteDataSource());
c.tagRepoLocal = TagRepo(TagSqliteDbDataSource(c.sqliteDb));
c.taggedFileRepo = const TaggedFileRepo(TaggedFileRemoteDataSource());
c.searchRepo = SearchRepo(SearchSqliteDbDataSource(c.sqliteDb));
if (platform_k.isAndroid) {
// local file currently only supported on Android

View file

@ -1,104 +0,0 @@
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/entity/album.dart';
abstract class AlbumSearchBlocEvent {
const AlbumSearchBlocEvent();
}
class AlbumSearchBlocSearchEvent extends AlbumSearchBlocEvent {
const AlbumSearchBlocSearchEvent(this.phrase);
@override
toString() {
return "$runtimeType {"
"phrase: '$phrase', "
"}";
}
final String phrase;
}
class AlbumSearchBlocUpdateItemsEvent extends AlbumSearchBlocEvent {
const AlbumSearchBlocUpdateItemsEvent(this.albums);
@override
toString() {
return "$runtimeType {"
"albums: List {legth: ${albums.length}}, "
"}";
}
final List<Album> albums;
}
abstract class AlbumSearchBlocState {
const AlbumSearchBlocState(this.results);
@override
toString() {
return "$runtimeType {"
"results: List {legth: ${results.length}}, "
"}";
}
final List<Album> results;
}
class AlbumSearchBlocInit extends AlbumSearchBlocState {
const AlbumSearchBlocInit() : super(const []);
}
class AlbumSearchBlocSuccess extends AlbumSearchBlocState {
const AlbumSearchBlocSuccess(List<Album> results) : super(results);
}
class AlbumSearchBloc extends Bloc<AlbumSearchBlocEvent, AlbumSearchBlocState> {
AlbumSearchBloc() : super(const AlbumSearchBlocInit()) {
on<AlbumSearchBlocEvent>(_onEvent);
}
Future<void> _onEvent(
AlbumSearchBlocEvent event, Emitter<AlbumSearchBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is AlbumSearchBlocSearchEvent) {
await _onEventSearch(event, emit);
} else if (event is AlbumSearchBlocUpdateItemsEvent) {
await _onEventUpdateItems(event, emit);
}
}
Future<void> _onEventSearch(
AlbumSearchBlocSearchEvent ev, Emitter<AlbumSearchBlocState> emit) async {
final matches = _albums
.where((element) =>
element.name.toLowerCase().contains(ev.phrase.toLowerCase()))
.sorted((a, b) {
final diffA = a.name.length - ev.phrase.length;
final diffB = b.name.length - ev.phrase.length;
final c = diffA.compareTo(diffB);
if (c != 0) {
return c;
} else {
return a.name.compareTo(b.name);
}
});
emit(AlbumSearchBlocSuccess(matches));
_lastSearch = ev;
}
Future<void> _onEventUpdateItems(AlbumSearchBlocUpdateItemsEvent ev,
Emitter<AlbumSearchBlocState> emit) async {
_albums = ev.albums;
if (_lastSearch != null) {
// search again
await _onEventSearch(_lastSearch!, emit);
}
}
var _albums = <Album>[];
AlbumSearchBlocSearchEvent? _lastSearch;
static final _log = Logger("bloc.album_search.AlbumSearchBloc");
}

View file

@ -0,0 +1,264 @@
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/use_case/list_album.dart';
import 'package:nc_photos/use_case/list_person.dart';
import 'package:nc_photos/use_case/list_tag.dart';
import 'package:tuple/tuple.dart';
import 'package:woozy_search/woozy_search.dart';
abstract class HomeSearchResult {}
class HomeSearchAlbumResult implements HomeSearchResult {
const HomeSearchAlbumResult(this.album);
@override
toString() => "$runtimeType {"
"album: $album, "
"}";
final Album album;
}
class HomeSearchTagResult implements HomeSearchResult {
const HomeSearchTagResult(this.tag);
@override
toString() => "$runtimeType {"
"tag: $tag, "
"}";
final Tag tag;
}
class HomeSearchPersonResult implements HomeSearchResult {
const HomeSearchPersonResult(this.person);
@override
toString() => "$runtimeType {"
"person: $person, "
"}";
final Person person;
}
abstract class HomeSearchSuggestionBlocEvent {
const HomeSearchSuggestionBlocEvent();
}
class HomeSearchSuggestionBlocPreloadData
extends HomeSearchSuggestionBlocEvent {
const HomeSearchSuggestionBlocPreloadData();
@override
toString() => "$runtimeType {"
"}";
}
class HomeSearchSuggestionBlocSearch extends HomeSearchSuggestionBlocEvent {
const HomeSearchSuggestionBlocSearch(this.phrase);
@override
toString() => "$runtimeType {"
"phrase: '$phrase', "
"}";
final CiString phrase;
}
abstract class HomeSearchSuggestionBlocState {
const HomeSearchSuggestionBlocState(this.results);
@override
toString() => "$runtimeType {"
"results: List {legth: ${results.length}}, "
"}";
final List<HomeSearchResult> results;
}
class HomeSearchSuggestionBlocInit extends HomeSearchSuggestionBlocState {
const HomeSearchSuggestionBlocInit() : super(const []);
}
class HomeSearchSuggestionBlocLoading extends HomeSearchSuggestionBlocState {
const HomeSearchSuggestionBlocLoading(List<HomeSearchResult> results)
: super(results);
}
class HomeSearchSuggestionBlocSuccess extends HomeSearchSuggestionBlocState {
const HomeSearchSuggestionBlocSuccess(List<HomeSearchResult> results)
: super(results);
}
class HomeSearchSuggestionBlocFailure extends HomeSearchSuggestionBlocState {
const HomeSearchSuggestionBlocFailure(
List<HomeSearchTagResult> results, this.exception)
: super(results);
@override
toString() => "$runtimeType {"
"super: ${super.toString()}, "
"exception: $exception, "
"}";
final Object exception;
}
class HomeSearchSuggestionBloc
extends Bloc<HomeSearchSuggestionBlocEvent, HomeSearchSuggestionBlocState> {
HomeSearchSuggestionBloc(this.account)
: super(const HomeSearchSuggestionBlocInit()) {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
assert(ListTag.require(c));
_c = c.withLocalRepo();
on<HomeSearchSuggestionBlocEvent>(_onEvent);
add(const HomeSearchSuggestionBlocPreloadData());
}
static bool require(DiContainer c) => true;
Future<void> _onEvent(HomeSearchSuggestionBlocEvent event,
Emitter<HomeSearchSuggestionBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is HomeSearchSuggestionBlocSearch) {
await _onEventSearch(event, emit);
} else if (event is HomeSearchSuggestionBlocPreloadData) {
await _onEventPreloadData(event, emit);
}
}
Future<void> _onEventSearch(HomeSearchSuggestionBlocSearch ev,
Emitter<HomeSearchSuggestionBlocState> emit) async {
if (ev.phrase.raw.isEmpty) {
emit(const HomeSearchSuggestionBlocSuccess([]));
return;
}
emit(HomeSearchSuggestionBlocLoading(state.results));
// doesn't work with upper case
final results = _search.search(ev.phrase.toCaseInsensitiveString());
if (kDebugMode) {
final str = results.map((e) => "${e.score}: ${e.text}").join("\n");
_log.fine("[_onEventSearch] Search '${ev.phrase}':\n$str");
}
final matches = results
.where((element) => element.score > 0)
.map((e) {
if (e.value!.toKeywords().any((k) => k.startsWith(ev.phrase))) {
// prefer names that start exactly with the search phrase
return Tuple2(e.score + 1, e.value);
} else {
return Tuple2(e.score, e.value);
}
})
.sorted((a, b) => b.item1.compareTo(a.item1))
.distinctIf(
(a, b) => identical(a.item2, b.item2),
(a) => a.item2.hashCode,
)
.map((e) => e.item2!.toResult())
.toList();
emit(HomeSearchSuggestionBlocSuccess(matches));
}
Future<void> _onEventPreloadData(HomeSearchSuggestionBlocPreloadData ev,
Emitter<HomeSearchSuggestionBlocState> emit) async {
final product = <_Searcheable>[];
try {
final albums = await ListAlbum(_c)(account)
.where((event) => event is Album)
.toList();
product.addAll(albums.map((a) => _AlbumSearcheable(a)));
_log.info("[_onEventPreloadData] Loaded ${albums.length} albums");
} catch (e) {
_log.warning("[_onEventPreloadData] Failed while ListAlbum", e);
}
try {
final tags = await ListTag(_c)(account);
product.addAll(tags.map((t) => _TagSearcheable(t)));
_log.info("[_onEventPreloadData] Loaded ${tags.length} tags");
} catch (e) {
_log.warning("[_onEventPreloadData] Failed while ListTag", e);
}
try {
final persons = await ListPerson(_c)(account);
product.addAll(persons.map((t) => _PersonSearcheable(t)));
_log.info("[_onEventPreloadData] Loaded ${persons.length} people");
} catch (e) {
_log.warning("[_onEventPreloadData] Failed while ListPerson", e);
}
_setSearchItems(product);
}
void _setSearchItems(List<_Searcheable> searcheables) {
_search.setEntries([]);
for (final s in searcheables) {
for (final k in s.toKeywords()) {
_search.addEntry(k.toCaseInsensitiveString(), value: s);
}
}
}
final Account account;
late final DiContainer _c;
final _search = Woozy<_Searcheable>(limit: 10);
static final _log =
Logger("bloc.album_search_suggestion.HomeSearchSuggestionBloc");
}
abstract class _Searcheable {
List<CiString> toKeywords();
HomeSearchResult toResult();
}
class _AlbumSearcheable implements _Searcheable {
const _AlbumSearcheable(this.album);
@override
toKeywords() => [album.name.toCi()];
@override
toResult() => HomeSearchAlbumResult(album);
final Album album;
}
class _TagSearcheable implements _Searcheable {
const _TagSearcheable(this.tag);
@override
toKeywords() => [tag.displayName.toCi()];
@override
toResult() => HomeSearchTagResult(tag);
final Tag tag;
}
class _PersonSearcheable implements _Searcheable {
const _PersonSearcheable(this.person);
@override
toKeywords() => [person.name.toCi()];
@override
toResult() => HomeSearchPersonResult(person);
final Person person;
}

View file

@ -0,0 +1,228 @@
import 'package:bloc/bloc.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/throttler.dart';
import 'package:nc_photos/use_case/find_file.dart';
abstract class ListTagFileBlocEvent {
const ListTagFileBlocEvent();
}
class ListTagFileBlocQuery extends ListTagFileBlocEvent {
const ListTagFileBlocQuery(this.account, this.tag);
@override
toString() => "$runtimeType {"
"account: $account, "
"tag: $tag, "
"}";
final Account account;
final Tag tag;
}
/// An external event has happened and may affect the state of this bloc
class _ListTagFileBlocExternalEvent extends ListTagFileBlocEvent {
const _ListTagFileBlocExternalEvent();
@override
toString() => "$runtimeType {"
"}";
}
abstract class ListTagFileBlocState {
const ListTagFileBlocState(this.account, this.items);
@override
toString() => "$runtimeType {"
"account: $account, "
"items: List {length: ${items.length}}, "
"}";
final Account? account;
final List<File> items;
}
class ListTagFileBlocInit extends ListTagFileBlocState {
ListTagFileBlocInit() : super(null, const []);
}
class ListTagFileBlocLoading extends ListTagFileBlocState {
const ListTagFileBlocLoading(Account? account, List<File> items)
: super(account, items);
}
class ListTagFileBlocSuccess extends ListTagFileBlocState {
const ListTagFileBlocSuccess(Account? account, List<File> items)
: super(account, items);
}
class ListTagFileBlocFailure extends ListTagFileBlocState {
const ListTagFileBlocFailure(
Account? account, List<File> items, this.exception)
: super(account, items);
@override
toString() => "$runtimeType {"
"super: ${super.toString()}, "
"exception: $exception, "
"}";
final Object exception;
}
/// The state of this bloc is inconsistent. This typically means that the data
/// may have been changed externally
class ListTagFileBlocInconsistent extends ListTagFileBlocState {
const ListTagFileBlocInconsistent(Account? account, List<File> items)
: super(account, items);
}
/// List all files associated with a specific tag
class ListTagFileBloc extends Bloc<ListTagFileBlocEvent, ListTagFileBlocState> {
ListTagFileBloc(this._c)
: assert(require(_c)),
// assert(PopulatePerson.require(_c)),
super(ListTagFileBlocInit()) {
_fileRemovedEventListener.begin();
_filePropertyUpdatedEventListener.begin();
on<ListTagFileBlocEvent>(_onEvent);
}
static bool require(DiContainer c) =>
DiContainer.has(c, DiType.taggedFileRepo);
@override
close() {
_fileRemovedEventListener.end();
_filePropertyUpdatedEventListener.end();
return super.close();
}
Future<void> _onEvent(
ListTagFileBlocEvent event, Emitter<ListTagFileBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is ListTagFileBlocQuery) {
await _onEventQuery(event, emit);
} else if (event is _ListTagFileBlocExternalEvent) {
await _onExternalEvent(event, emit);
}
}
Future<void> _onEventQuery(
ListTagFileBlocQuery ev, Emitter<ListTagFileBlocState> emit) async {
try {
emit(ListTagFileBlocLoading(ev.account, state.items));
emit(ListTagFileBlocSuccess(ev.account, await _query(ev)));
} catch (e, stackTrace) {
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
emit(ListTagFileBlocFailure(ev.account, state.items, e));
}
}
Future<void> _onExternalEvent(_ListTagFileBlocExternalEvent ev,
Emitter<ListTagFileBlocState> emit) async {
emit(ListTagFileBlocInconsistent(state.account, state.items));
}
void _onFileRemovedEvent(FileRemovedEvent ev) {
if (state is ListTagFileBlocInit) {
// no data in this bloc, ignore
return;
}
if (_isFileOfInterest(ev.file)) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
}
}
void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) {
if (!ev.hasAnyProperties([
FilePropertyUpdatedEvent.propMetadata,
FilePropertyUpdatedEvent.propIsArchived,
FilePropertyUpdatedEvent.propOverrideDateTime,
FilePropertyUpdatedEvent.propFavorite,
])) {
// not interested
return;
}
if (state is ListTagFileBlocInit) {
// no data in this bloc, ignore
return;
}
if (!_isFileOfInterest(ev.file)) {
return;
}
if (ev.hasAnyProperties([
FilePropertyUpdatedEvent.propIsArchived,
FilePropertyUpdatedEvent.propOverrideDateTime,
FilePropertyUpdatedEvent.propFavorite,
])) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
} else {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 10),
maxPendingCount: 10,
);
}
}
Future<List<File>> _query(ListTagFileBlocQuery ev) async {
final files = <File>[];
for (final r in ev.account.roots) {
final dir = File(path: file_util.unstripPath(ev.account, r));
final taggedFiles =
await _c.taggedFileRepo.list(ev.account, dir, [ev.tag]);
files.addAll(await FindFile(_c)(
ev.account,
taggedFiles.map((e) => e.fileId).toList(),
onFileNotFound: (id) {
_log.warning("[_query] Missing file: $id");
},
));
}
return files.where((f) => file_util.isSupportedFormat(f)).toList();
}
bool _isFileOfInterest(File file) {
if (!file_util.isSupportedFormat(file)) {
return false;
}
for (final r in state.account?.roots ?? []) {
final dir = File(path: file_util.unstripPath(state.account!, r));
if (file_util.isUnderDir(file, dir)) {
return true;
}
}
return false;
}
final DiContainer _c;
late final _fileRemovedEventListener =
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
late final _filePropertyUpdatedEventListener =
AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdatedEvent);
late final _refreshThrottler = Throttler(
onTriggered: (_) {
add(const _ListTagFileBlocExternalEvent());
},
logTag: "ListTagFileBloc.refresh",
);
static final _log = Logger("bloc.list_tag_file.ListTagFileBloc");
}

232
app/lib/bloc/search.dart Normal file
View file

@ -0,0 +1,232 @@
import 'package:bloc/bloc.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/search.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/throttler.dart';
import 'package:nc_photos/use_case/search.dart';
abstract class SearchBlocEvent {
const SearchBlocEvent();
}
class SearchBlocQuery extends SearchBlocEvent {
const SearchBlocQuery(this.account, this.criteria);
@override
toString() => "$runtimeType {"
"account: $account, "
"criteria: $criteria, "
"}";
final Account account;
final SearchCriteria criteria;
}
/// An external event has happened and may affect the state of this bloc
class _SearchBlocExternalEvent extends SearchBlocEvent {
const _SearchBlocExternalEvent();
@override
toString() => "$runtimeType {"
"}";
}
class SearchBlocResetLanding extends SearchBlocEvent {
const SearchBlocResetLanding(this.account);
@override
toString() => "$runtimeType {"
"account: $account, "
"}";
final Account account;
}
abstract class SearchBlocState {
const SearchBlocState(this.account, this.criteria, this.items);
@override
toString() => "$runtimeType {"
"account: $account, "
"criteria: $criteria, "
"items: List {length: ${items.length}}, "
"}";
final Account? account;
final SearchCriteria criteria;
final List<File> items;
}
class SearchBlocInit extends SearchBlocState {
SearchBlocInit() : super(null, const SearchCriteria({}, []), const []);
}
class SearchBlocLoading extends SearchBlocState {
const SearchBlocLoading(
Account? account, SearchCriteria criteria, List<File> items)
: super(account, criteria, items);
}
class SearchBlocSuccess extends SearchBlocState {
const SearchBlocSuccess(
Account? account, SearchCriteria criteria, List<File> items)
: super(account, criteria, items);
}
class SearchBlocFailure extends SearchBlocState {
const SearchBlocFailure(Account? account, SearchCriteria criteria,
List<File> items, this.exception)
: super(account, criteria, items);
@override
toString() => "$runtimeType {"
"super: ${super.toString()}, "
"exception: $exception, "
"}";
final Object exception;
}
/// The state of this bloc is inconsistent. This typically means that the data
/// may have been changed externally
class SearchBlocInconsistent extends SearchBlocState {
const SearchBlocInconsistent(
Account? account, SearchCriteria criteria, List<File> items)
: super(account, criteria, items);
}
class SearchBloc extends Bloc<SearchBlocEvent, SearchBlocState> {
SearchBloc(this._c)
: assert(require(_c)),
assert(Search.require(_c)),
super(SearchBlocInit()) {
_fileRemovedEventListener.begin();
_filePropertyUpdatedEventListener.begin();
// not listening to restore event because search works only with local data
// sources and they are not aware of restore events
on<SearchBlocEvent>(_onEvent);
}
static bool require(DiContainer c) => true;
@override
close() {
_fileRemovedEventListener.end();
_filePropertyUpdatedEventListener.end();
return super.close();
}
Future<void> _onEvent(
SearchBlocEvent event, Emitter<SearchBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is SearchBlocQuery) {
await _onEventQuery(event, emit);
} else if (event is SearchBlocResetLanding) {
emit(SearchBlocInit());
} else if (event is _SearchBlocExternalEvent) {
await _onExternalEvent(event, emit);
}
}
Future<void> _onEventQuery(
SearchBlocQuery ev, Emitter<SearchBlocState> emit) async {
try {
emit(SearchBlocLoading(ev.account, ev.criteria, state.items));
emit(SearchBlocSuccess(ev.account, ev.criteria, await _query(ev)));
} catch (e, stackTrace) {
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
emit(SearchBlocFailure(ev.account, ev.criteria, state.items, e));
}
}
Future<void> _onExternalEvent(
_SearchBlocExternalEvent ev, Emitter<SearchBlocState> emit) async {
emit(SearchBlocInconsistent(state.account, state.criteria, state.items));
}
void _onFileRemovedEvent(FileRemovedEvent ev) {
if (state is SearchBlocInit) {
// no data in this bloc, ignore
return;
}
if (_isFileOfInterest(ev.file)) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
}
}
void _onFilePropertyUpdatedEvent(FilePropertyUpdatedEvent ev) {
if (!ev.hasAnyProperties([
FilePropertyUpdatedEvent.propMetadata,
FilePropertyUpdatedEvent.propIsArchived,
FilePropertyUpdatedEvent.propOverrideDateTime,
FilePropertyUpdatedEvent.propFavorite,
])) {
// not interested
return;
}
if (state is SearchBlocInit) {
// no data in this bloc, ignore
return;
}
if (!_isFileOfInterest(ev.file)) {
return;
}
if (ev.hasAnyProperties([
FilePropertyUpdatedEvent.propIsArchived,
FilePropertyUpdatedEvent.propOverrideDateTime,
FilePropertyUpdatedEvent.propFavorite,
])) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
} else {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 10),
maxPendingCount: 10,
);
}
}
Future<List<File>> _query(SearchBlocQuery ev) =>
Search(_c)(ev.account, ev.criteria);
bool _isFileOfInterest(File file) {
if (!file_util.isSupportedFormat(file)) {
return false;
}
for (final r in state.account?.roots ?? []) {
final dir = File(path: file_util.unstripPath(state.account!, r));
if (file_util.isUnderDir(file, dir)) {
return true;
}
}
return false;
}
final DiContainer _c;
late final _fileRemovedEventListener =
AppEventListener<FileRemovedEvent>(_onFileRemovedEvent);
late final _filePropertyUpdatedEventListener =
AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdatedEvent);
late final _refreshThrottler = Throttler(
onTriggered: (_) {
add(const _SearchBlocExternalEvent());
},
logTag: "SearchBloc.refresh",
);
static final _log = Logger("bloc.search.SearchBloc");
}

View file

@ -0,0 +1,99 @@
import 'package:bloc/bloc.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/use_case/list_person.dart';
abstract class SearchLandingBlocEvent {
const SearchLandingBlocEvent();
}
class SearchLandingBlocQuery extends SearchLandingBlocEvent {
const SearchLandingBlocQuery(this.account);
@override
toString() => "$runtimeType {"
"account: $account, "
"}";
final Account account;
}
abstract class SearchLandingBlocState {
const SearchLandingBlocState(this.account, this.persons);
@override
toString() => "$runtimeType {"
"account: $account, "
"persons: List {length: ${persons.length}}, "
"}";
final Account? account;
final List<Person> persons;
}
class SearchLandingBlocInit extends SearchLandingBlocState {
SearchLandingBlocInit() : super(null, const []);
}
class SearchLandingBlocLoading extends SearchLandingBlocState {
const SearchLandingBlocLoading(Account? account, List<Person> persons)
: super(account, persons);
}
class SearchLandingBlocSuccess extends SearchLandingBlocState {
const SearchLandingBlocSuccess(Account? account, List<Person> persons)
: super(account, persons);
}
class SearchLandingBlocFailure extends SearchLandingBlocState {
const SearchLandingBlocFailure(
Account? account, List<Person> persons, this.exception)
: super(account, persons);
@override
toString() => "$runtimeType {"
"super: ${super.toString()}, "
"exception: $exception, "
"}";
final Object exception;
}
class SearchLandingBloc
extends Bloc<SearchLandingBlocEvent, SearchLandingBlocState> {
SearchLandingBloc(this._c)
: assert(require(_c)),
super(SearchLandingBlocInit()) {
on<SearchLandingBlocEvent>(_onEvent);
}
static bool require(DiContainer c) => true;
Future<void> _onEvent(SearchLandingBlocEvent event,
Emitter<SearchLandingBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is SearchLandingBlocQuery) {
await _onEventQuery(event, emit);
}
}
Future<void> _onEventQuery(
SearchLandingBlocQuery ev, Emitter<SearchLandingBlocState> emit) async {
try {
emit(SearchLandingBlocLoading(ev.account, state.persons));
emit(SearchLandingBlocSuccess(ev.account, await _query(ev)));
} catch (e, stackTrace) {
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
emit(SearchLandingBlocFailure(ev.account, state.persons, e));
}
}
Future<List<Person>> _query(SearchLandingBlocQuery ev) =>
ListPerson(_c.withLocalRepo())(ev.account);
final DiContainer _c;
static final _log = Logger("bloc.search_landing.SearchLandingBloc");
}

View file

@ -4,6 +4,7 @@ import 'package:nc_photos/entity/favorite.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/entity/search.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/sharee.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
@ -30,6 +31,7 @@ enum DiType {
tagRepoLocal,
taggedFileRepo,
localFileRepo,
searchRepo,
pref,
sqliteDb,
}
@ -53,6 +55,7 @@ class DiContainer {
TagRepo? tagRepoLocal,
TaggedFileRepo? taggedFileRepo,
LocalFileRepo? localFileRepo,
SearchRepo? searchRepo,
Pref? pref,
sql.SqliteDb? sqliteDb,
}) : _albumRepo = albumRepo,
@ -72,6 +75,7 @@ class DiContainer {
_tagRepoLocal = tagRepoLocal,
_taggedFileRepo = taggedFileRepo,
_localFileRepo = localFileRepo,
_searchRepo = searchRepo,
_pref = pref,
_sqliteDb = sqliteDb;
@ -113,6 +117,8 @@ class DiContainer {
return contianer._taggedFileRepo != null;
case DiType.localFileRepo:
return contianer._localFileRepo != null;
case DiType.searchRepo:
return contianer._searchRepo != null;
case DiType.pref:
return contianer._pref != null;
case DiType.sqliteDb:
@ -131,6 +137,7 @@ class DiContainer {
OrNull<TagRepo>? tagRepo,
OrNull<TaggedFileRepo>? taggedFileRepo,
OrNull<LocalFileRepo>? localFileRepo,
OrNull<SearchRepo>? searchRepo,
OrNull<Pref>? pref,
OrNull<sql.SqliteDb>? sqliteDb,
}) {
@ -146,6 +153,7 @@ class DiContainer {
taggedFileRepo:
taggedFileRepo == null ? _taggedFileRepo : taggedFileRepo.obj,
localFileRepo: localFileRepo == null ? _localFileRepo : localFileRepo.obj,
searchRepo: searchRepo == null ? _searchRepo : searchRepo.obj,
pref: pref == null ? _pref : pref.obj,
sqliteDb: sqliteDb == null ? _sqliteDb : sqliteDb.obj,
);
@ -168,6 +176,7 @@ class DiContainer {
TagRepo get tagRepoLocal => _tagRepoLocal!;
TaggedFileRepo get taggedFileRepo => _taggedFileRepo!;
LocalFileRepo get localFileRepo => _localFileRepo!;
SearchRepo get searchRepo => _searchRepo!;
sql.SqliteDb get sqliteDb => _sqliteDb!;
Pref get pref => _pref!;
@ -257,6 +266,11 @@ class DiContainer {
_localFileRepo = v;
}
set searchRepo(SearchRepo v) {
assert(_searchRepo == null);
_searchRepo = v;
}
set sqliteDb(sql.SqliteDb v) {
assert(_sqliteDb == null);
_sqliteDb = v;
@ -287,12 +301,23 @@ class DiContainer {
TagRepo? _tagRepoLocal;
TaggedFileRepo? _taggedFileRepo;
LocalFileRepo? _localFileRepo;
SearchRepo? _searchRepo;
sql.SqliteDb? _sqliteDb;
Pref? _pref;
}
extension DiContainerExtension on DiContainer {
/// Uses local repo if available
///
/// Notice that not all repo support this
DiContainer withLocalRepo() => copyWith(
albumRepo: OrNull(albumRepoLocal),
fileRepo: OrNull(fileRepoLocal),
personRepo: OrNull(personRepoLocal),
tagRepo: OrNull(tagRepoLocal),
);
DiContainer withLocalAlbumRepo() =>
copyWith(albumRepo: OrNull(albumRepoLocal));
DiContainer withRemoteFileRepo() =>

View file

@ -0,0 +1,94 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/iterable_extension.dart';
class SearchCriteria {
const SearchCriteria(this.keywords, this.filters);
SearchCriteria copyWith({
Set<CiString>? keywords,
List<SearchFilter>? filters,
}) =>
SearchCriteria(
keywords ?? Set.of(this.keywords),
filters ?? List.of(this.filters),
);
@override
toString() => "$runtimeType {"
"keywords: ${keywords.toReadableString()}, "
"filters: ${filters.toReadableString()}, "
"}";
final Set<CiString> keywords;
final List<SearchFilter> filters;
}
abstract class SearchFilter {
void apply(sql.FilesQueryBuilder query);
}
enum SearchFileType {
image,
video,
}
extension on SearchFileType {
String toSqlPattern() {
switch (this) {
case SearchFileType.image:
return "image/%";
case SearchFileType.video:
return "video/%";
}
}
}
class SearchFileTypeFilter implements SearchFilter {
const SearchFileTypeFilter(this.type);
@override
apply(sql.FilesQueryBuilder query) {
query.byMimePattern(type.toSqlPattern());
}
@override
toString() => "$runtimeType {"
"type: ${type.name}, "
"}";
final SearchFileType type;
}
class SearchFavoriteFilter implements SearchFilter {
const SearchFavoriteFilter(this.value);
@override
apply(sql.FilesQueryBuilder query) {
query.byFavorite(value);
}
@override
toString() => "$runtimeType {"
"value: $value, "
"}";
final bool value;
}
class SearchRepo {
const SearchRepo(this.dataSrc);
Future<List<File>> list(Account account, SearchCriteria criteria) =>
dataSrc.list(account, criteria);
final SearchDataSource dataSrc;
}
abstract class SearchDataSource {
/// List all results from a given search criteria
Future<List<File>> list(Account account, SearchCriteria criteria);
}

View file

@ -0,0 +1,53 @@
import 'package:drift/drift.dart' as sql;
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/search.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/object_extension.dart';
class SearchSqliteDbDataSource implements SearchDataSource {
SearchSqliteDbDataSource(this.sqliteDb);
@override
list(Account account, SearchCriteria criteria) async {
_log.info("[list] $criteria");
final keywords =
criteria.keywords.map((e) => e.toCaseInsensitiveString()).toList();
final dbFiles = await sqliteDb.use((db) async {
final query = db.queryFiles().run((q) {
q.setQueryMode(sql.FilesQueryMode.completeFile);
q.setAppAccount(account);
for (final r in account.roots) {
if (r.isNotEmpty) {
q.byOrRelativePathPattern("$r/%");
}
}
for (final f in criteria.filters) {
f.apply(q);
}
return q.build();
});
// limit to supported formats only
query.where(db.files.contentType.like("image/%") |
db.files.contentType.like("video/%"));
for (final k in keywords) {
query.where(db.accountFiles.relativePath.like("%$k%"));
}
return await query
.map((r) => sql.CompleteFile(
r.readTable(db.files),
r.readTable(db.accountFiles),
r.readTableOrNull(db.images),
r.readTableOrNull(db.trashes),
))
.get();
});
return await dbFiles.convertToAppFile(account);
}
final sql.SqliteDb sqliteDb;
static final _log =
Logger("entity.search.data_source.SearchSqliteDbDataSource");
}

View file

@ -0,0 +1,3 @@
/// Covert all symbols to whitespace
String cleanUpSymbols(String s) =>
s.replaceAll(RegExp(r"(?:_|[^\p{L}\d])+", unicode: true), " ");

View file

@ -1301,6 +1301,71 @@
"@imageEditTitle": {
"description": "Title of the image editor"
},
"categoriesLabel": "Categories",
"searchLandingPeopleListEmptyText": "Press help to learn how to setup",
"@searchLandingPeopleListEmptyText": {
"description": "Shown in the search landing page under the People section when there are no people"
},
"searchLandingCategoryVideosLabel": "Videos",
"@searchLandingCategoryVideosLabel": {
"description": "Search all videos"
},
"searchFilterButtonLabel": "FILTERS",
"@searchFilterButtonLabel": {
"description": "Modify search filters"
},
"searchFilterDialogTitle": "Search filters",
"@searchFilterDialogTitle": {
"description": "Dialog to modify search filters"
},
"applyButtonLabel": "APPLY",
"@applyButtonLabel": {
"description": "A confirmation button, typically in a dialog, that apply the current settings"
},
"searchFilterOptionAnyLabel": "Any",
"@searchFilterOptionAnyLabel": {
"description": "This is the default option for all search filters. Filters with this value will be ignored"
},
"searchFilterOptionTrueLabel": "True",
"@searchFilterOptionTrueLabel": {
"description": "Positive option for a boolean filter"
},
"searchFilterOptionFalseLabel": "False",
"@searchFilterOptionFalseLabel": {
"description": "Negative option for a boolean filter"
},
"searchFilterTypeLabel": "Type",
"@searchFilterTypeLabel": {
"description": "Filter search results by file type"
},
"searchFilterTypeOptionImageLabel": "Image",
"@searchFilterTypeOptionImageLabel": {
"description": "Filter search results by file type"
},
"searchFilterBubbleTypeImageText": "images",
"@searchFilterBubbleTypeImageText": {
"description": "List of active search filters shown in the result page (by file type, image)"
},
"searchFilterTypeOptionVideoLabel": "Video",
"@searchFilterTypeOptionVideoLabel": {
"description": "Filter search results by file type"
},
"searchFilterBubbleTypeVideoText": "videos",
"@searchFilterBubbleTypeVideoText": {
"description": "List of active search filters shown in the result page (by file type, video)"
},
"searchFilterFavoriteLabel": "Favorite",
"@searchFilterFavoriteLabel": {
"description": "Filter search results by whether it's in favorites"
},
"searchFilterBubbleFavoriteTrueText": "favorites",
"@searchFilterBubbleFavoriteTrueText": {
"description": "List of active search filters shown in the result page (by favorites, true)"
},
"searchFilterBubbleFavoriteFalseText": "not favorites",
"@searchFilterBubbleFavoriteFalseText": {
"description": "List of active search filters shown in the result page (by favorites, false)"
},
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
"@errorUnauthenticated": {

View file

@ -124,6 +124,23 @@
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
"searchFilterButtonLabel",
"searchFilterDialogTitle",
"applyButtonLabel",
"searchFilterOptionAnyLabel",
"searchFilterOptionTrueLabel",
"searchFilterOptionFalseLabel",
"searchFilterTypeLabel",
"searchFilterTypeOptionImageLabel",
"searchFilterBubbleTypeImageText",
"searchFilterTypeOptionVideoLabel",
"searchFilterBubbleTypeVideoText",
"searchFilterFavoriteLabel",
"searchFilterBubbleFavoriteTrueText",
"searchFilterBubbleFavoriteFalseText",
"errorAlbumDowngrade"
],
@ -266,6 +283,23 @@
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
"searchFilterButtonLabel",
"searchFilterDialogTitle",
"applyButtonLabel",
"searchFilterOptionAnyLabel",
"searchFilterOptionTrueLabel",
"searchFilterOptionFalseLabel",
"searchFilterTypeLabel",
"searchFilterTypeOptionImageLabel",
"searchFilterBubbleTypeImageText",
"searchFilterTypeOptionVideoLabel",
"searchFilterBubbleTypeVideoText",
"searchFilterFavoriteLabel",
"searchFilterBubbleFavoriteTrueText",
"searchFilterBubbleFavoriteFalseText",
"errorAlbumDowngrade"
],
@ -288,11 +322,65 @@
"imageEditColorSaturation",
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle"
"imageEditTitle",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
"searchFilterButtonLabel",
"searchFilterDialogTitle",
"applyButtonLabel",
"searchFilterOptionAnyLabel",
"searchFilterOptionTrueLabel",
"searchFilterOptionFalseLabel",
"searchFilterTypeLabel",
"searchFilterTypeOptionImageLabel",
"searchFilterBubbleTypeImageText",
"searchFilterTypeOptionVideoLabel",
"searchFilterBubbleTypeVideoText",
"searchFilterFavoriteLabel",
"searchFilterBubbleFavoriteTrueText",
"searchFilterBubbleFavoriteFalseText"
],
"es": [
"rootPickerSkipConfirmationDialogContent2"
"rootPickerSkipConfirmationDialogContent2",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
"searchFilterButtonLabel",
"searchFilterDialogTitle",
"applyButtonLabel",
"searchFilterOptionAnyLabel",
"searchFilterOptionTrueLabel",
"searchFilterOptionFalseLabel",
"searchFilterTypeLabel",
"searchFilterTypeOptionImageLabel",
"searchFilterBubbleTypeImageText",
"searchFilterTypeOptionVideoLabel",
"searchFilterBubbleTypeVideoText",
"searchFilterFavoriteLabel",
"searchFilterBubbleFavoriteTrueText",
"searchFilterBubbleFavoriteFalseText"
],
"fi": [
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
"searchFilterButtonLabel",
"searchFilterDialogTitle",
"applyButtonLabel",
"searchFilterOptionAnyLabel",
"searchFilterOptionTrueLabel",
"searchFilterOptionFalseLabel",
"searchFilterTypeLabel",
"searchFilterTypeOptionImageLabel",
"searchFilterBubbleTypeImageText",
"searchFilterTypeOptionVideoLabel",
"searchFilterBubbleTypeVideoText",
"searchFilterFavoriteLabel",
"searchFilterBubbleFavoriteTrueText",
"searchFilterBubbleFavoriteFalseText"
],
"fr": [
@ -339,7 +427,24 @@
"imageEditColorSaturation",
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle"
"imageEditTitle",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
"searchFilterButtonLabel",
"searchFilterDialogTitle",
"applyButtonLabel",
"searchFilterOptionAnyLabel",
"searchFilterOptionTrueLabel",
"searchFilterOptionFalseLabel",
"searchFilterTypeLabel",
"searchFilterTypeOptionImageLabel",
"searchFilterBubbleTypeImageText",
"searchFilterTypeOptionVideoLabel",
"searchFilterBubbleTypeVideoText",
"searchFilterFavoriteLabel",
"searchFilterBubbleFavoriteTrueText",
"searchFilterBubbleFavoriteFalseText"
],
"pl": [
@ -403,7 +508,24 @@
"imageEditColorSaturation",
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle"
"imageEditTitle",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
"searchFilterButtonLabel",
"searchFilterDialogTitle",
"applyButtonLabel",
"searchFilterOptionAnyLabel",
"searchFilterOptionTrueLabel",
"searchFilterOptionFalseLabel",
"searchFilterTypeLabel",
"searchFilterTypeOptionImageLabel",
"searchFilterBubbleTypeImageText",
"searchFilterTypeOptionVideoLabel",
"searchFilterBubbleTypeVideoText",
"searchFilterFavoriteLabel",
"searchFilterBubbleFavoriteTrueText",
"searchFilterBubbleFavoriteFalseText"
],
"pt": [
@ -446,7 +568,24 @@
"imageEditColorSaturation",
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle"
"imageEditTitle",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
"searchFilterButtonLabel",
"searchFilterDialogTitle",
"applyButtonLabel",
"searchFilterOptionAnyLabel",
"searchFilterOptionTrueLabel",
"searchFilterOptionFalseLabel",
"searchFilterTypeLabel",
"searchFilterTypeOptionImageLabel",
"searchFilterBubbleTypeImageText",
"searchFilterTypeOptionVideoLabel",
"searchFilterBubbleTypeVideoText",
"searchFilterFavoriteLabel",
"searchFilterBubbleFavoriteTrueText",
"searchFilterBubbleFavoriteFalseText"
],
"ru": [
@ -489,7 +628,24 @@
"imageEditColorSaturation",
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle"
"imageEditTitle",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
"searchFilterButtonLabel",
"searchFilterDialogTitle",
"applyButtonLabel",
"searchFilterOptionAnyLabel",
"searchFilterOptionTrueLabel",
"searchFilterOptionFalseLabel",
"searchFilterTypeLabel",
"searchFilterTypeOptionImageLabel",
"searchFilterBubbleTypeImageText",
"searchFilterTypeOptionVideoLabel",
"searchFilterBubbleTypeVideoText",
"searchFilterFavoriteLabel",
"searchFilterBubbleFavoriteTrueText",
"searchFilterBubbleFavoriteFalseText"
],
"zh": [
@ -532,7 +688,24 @@
"imageEditColorSaturation",
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle"
"imageEditTitle",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
"searchFilterButtonLabel",
"searchFilterDialogTitle",
"applyButtonLabel",
"searchFilterOptionAnyLabel",
"searchFilterOptionTrueLabel",
"searchFilterOptionFalseLabel",
"searchFilterTypeLabel",
"searchFilterTypeOptionImageLabel",
"searchFilterBubbleTypeImageText",
"searchFilterTypeOptionVideoLabel",
"searchFilterBubbleTypeVideoText",
"searchFilterFavoriteLabel",
"searchFilterBubbleFavoriteTrueText",
"searchFilterBubbleFavoriteFalseText"
],
"zh_Hant": [
@ -575,6 +748,23 @@
"imageEditColorSaturation",
"imageEditColorWarmth",
"imageEditColorTint",
"imageEditTitle"
"imageEditTitle",
"categoriesLabel",
"searchLandingPeopleListEmptyText",
"searchLandingCategoryVideosLabel",
"searchFilterButtonLabel",
"searchFilterDialogTitle",
"applyButtonLabel",
"searchFilterOptionAnyLabel",
"searchFilterOptionTrueLabel",
"searchFilterOptionFalseLabel",
"searchFilterTypeLabel",
"searchFilterTypeOptionImageLabel",
"searchFilterBubbleTypeImageText",
"searchFilterTypeOptionVideoLabel",
"searchFilterBubbleTypeVideoText",
"searchFilterFavoriteLabel",
"searchFilterBubbleFavoriteTrueText",
"searchFilterBubbleFavoriteFalseText"
]
}

View file

@ -0,0 +1,18 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/search.dart';
class Search {
Search(this._c) : assert(require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.searchRepo);
Future<List<File>> call(Account account, SearchCriteria criteria) async {
final files = await _c.searchRepo.list(account, criteria);
return files.where((f) => file_util.isSupportedFormat(f)).toList();
}
final DiContainer _c;
}

View file

@ -1,141 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:kiwi/kiwi.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/album_search.dart';
import 'package:nc_photos/bloc/search_suggestion.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/list_album.dart';
import 'package:nc_photos/widget/builder/album_grid_item_builder.dart';
import 'package:nc_photos/widget/empty_list_indicator.dart';
/// Search and filter albums (to be replaced by a more universal search in the
/// future)
class AlbumSearchDelegate extends SearchDelegate {
AlbumSearchDelegate(BuildContext context, this.account)
: super(
searchFieldLabel: L10n.global().albumSearchTextFieldHint,
) {
ListAlbum(KiwiContainer().resolve<DiContainer>())(account)
.toList()
.then((value) {
final albums = value.whereType<Album>().toList();
_searchBloc.add(AlbumSearchBlocUpdateItemsEvent(albums));
_suggestionBloc.add(SearchSuggestionBlocUpdateItemsEvent<Album>(albums));
});
}
@override
ThemeData appBarTheme(BuildContext context) =>
AppTheme.buildThemeData(context);
@override
buildActions(BuildContext context) {
return [
IconButton(
icon: const Icon(Icons.clear),
tooltip: L10n.global().clearTooltip,
onPressed: () {
query = "";
},
),
];
}
@override
buildLeading(BuildContext context) {
return BackButton(
onPressed: () {
close(context, null);
},
);
}
@override
buildResults(BuildContext context) {
_searchBloc.add(AlbumSearchBlocSearchEvent(query));
return BlocBuilder<AlbumSearchBloc, AlbumSearchBlocState>(
bloc: _searchBloc,
builder: _buildResultContent,
);
}
@override
buildSuggestions(BuildContext context) {
_suggestionBloc.add(SearchSuggestionBlocSearchEvent<Album>(query.toCi()));
return BlocBuilder<SearchSuggestionBloc<Album>,
SearchSuggestionBlocState<Album>>(
bloc: _suggestionBloc,
builder: _buildSuggestionContent,
);
}
Widget _buildResultContent(BuildContext context, AlbumSearchBlocState state) {
if (state.results.isEmpty) {
return EmptyListIndicator(
icon: Icons.mood_bad,
text: L10n.global().listNoResultsText,
);
} else {
return StaggeredGridView.extentBuilder(
maxCrossAxisExtent: 256,
mainAxisSpacing: 8,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: state.results.length,
itemBuilder: (contex, index) =>
_buildResultItem(context, state.results[index]),
staggeredTileBuilder: (_) => const StaggeredTile.count(1, 1),
);
}
}
Widget _buildResultItem(BuildContext context, Album album) {
return Stack(
children: [
AlbumGridItemBuilder(
account: account,
album: album,
).build(context),
Positioned.fill(
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () {
close(context, album);
},
),
),
),
],
);
}
Widget _buildSuggestionContent(
BuildContext context, SearchSuggestionBlocState<Album> state) {
return SingleChildScrollView(
child: Column(
children: state.results
.map((e) => ListTile(
title: Text(e.name),
onTap: () {
query = e.name;
showResults(context);
},
))
.toList(),
),
);
}
final Account account;
final _searchBloc = AlbumSearchBloc();
final _suggestionBloc = SearchSuggestionBloc<Album>(
itemToKeywords: (item) => [item.name.toCi()],
);
}

View file

@ -15,6 +15,7 @@ import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/import_potential_shared_album.dart';
import 'package:nc_photos/widget/home_albums.dart';
import 'package:nc_photos/widget/home_photos.dart';
import 'package:nc_photos/widget/home_search.dart';
class HomeArguments {
HomeArguments(this.account);
@ -85,6 +86,10 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
icon: const Icon(Icons.photo_outlined),
label: L10n.global().photosTabLabel,
),
BottomNavigationBarItem(
icon: const Icon(Icons.search),
label: L10n.global().searchTooltip,
),
BottomNavigationBarItem(
icon: const Icon(Icons.grid_view_outlined),
label: L10n.global().collectionsTooltip,
@ -100,7 +105,7 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
return PageView.builder(
controller: _pageController,
physics: const NeverScrollableScrollPhysics(),
itemCount: 2,
itemCount: 3,
itemBuilder: (context, index) => SlideTransition(
position: Tween(
begin: const Offset(0, .05),
@ -120,6 +125,9 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
return _buildPhotosPage(context);
case 1:
return _buildSearchPage(context);
case 2:
return _buildAlbumsPage(context);
default:
@ -133,6 +141,12 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
);
}
Widget _buildSearchPage(BuildContext context) {
return HomeSearch(
account: widget.account,
);
}
Widget _buildAlbumsPage(BuildContext context) {
return HomeAlbums(
account: widget.account,

View file

@ -26,7 +26,6 @@ import 'package:nc_photos/use_case/remove_album.dart';
import 'package:nc_photos/use_case/unimport_shared_album.dart';
import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util;
import 'package:nc_photos/widget/album_importer.dart';
import 'package:nc_photos/widget/album_search_delegate.dart';
import 'package:nc_photos/widget/archive_browser.dart';
import 'package:nc_photos/widget/builder/album_grid_item_builder.dart';
import 'package:nc_photos/widget/dynamic_album_browser.dart';
@ -37,7 +36,6 @@ import 'package:nc_photos/widget/handler/double_tap_exit_handler.dart';
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/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/sharing_browser.dart';
@ -207,13 +205,6 @@ class _HomeAlbumsState extends State<HomeAlbums>
Widget _buildNormalAppBar(BuildContext context) {
return HomeSliverAppBar(
account: widget.account,
actions: [
IconButton(
onPressed: () => _onSearchPressed(context),
icon: const Icon(Icons.search),
tooltip: L10n.global().searchTooltip,
),
],
menuActions: [
PopupMenuItem(
value: _menuValueSort,
@ -251,19 +242,6 @@ class _HomeAlbumsState extends State<HomeAlbums>
);
}
SelectableItem _buildPersonItem(BuildContext context) {
return _ButtonListItem(
icon: Icons.person_outlined,
label: L10n.global().collectionPeopleLabel,
onTap: () {
if (!isSelectionMode) {
Navigator.of(context).pushNamed(PeopleBrowser.routeName,
arguments: PeopleBrowserArguments(widget.account));
}
},
);
}
SelectableItem _buildArchiveItem(BuildContext context) {
return _ButtonListItem(
icon: Icons.archive_outlined,
@ -379,17 +357,6 @@ class _HomeAlbumsState extends State<HomeAlbums>
_reqQuery();
}
void _onSearchPressed(BuildContext context) {
showSearch(
context: context,
delegate: AlbumSearchDelegate(context, widget.account),
).then((value) {
if (value is Album) {
_openAlbum(context, value);
}
});
}
void _onSortPressed(BuildContext context) {
showDialog(
context: context,
@ -514,8 +481,6 @@ class _HomeAlbumsState extends State<HomeAlbums>
album_util.sorted(items.map((e) => e.album).toList(), sort);
itemStreamListItems = [
_buildFavoriteItem(context),
if (AccountPref.of(widget.account).isEnableFaceRecognitionAppOr())
_buildPersonItem(context),
_buildSharingItem(context),
if (features.isSupportEnhancement) _buildEnhancedPhotosItem(context),
_buildArchiveItem(context),

View file

@ -0,0 +1,849 @@
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/search.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/compute_queue.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/download_handler.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/search.dart';
import 'package:nc_photos/entity/search_util.dart' as search_util;
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/share_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/throttler.dart';
import 'package:nc_photos/widget/animated_visibility.dart';
import 'package:nc_photos/widget/builder/photo_list_item_builder.dart';
import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart';
import 'package:nc_photos/widget/handler/archive_selection_handler.dart';
import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
import 'package:nc_photos/widget/home_search_suggestion.dart';
import 'package:nc_photos/widget/page_visibility_mixin.dart';
import 'package:nc_photos/widget/photo_list_item.dart';
import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util;
import 'package:nc_photos/widget/search_landing.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';
class HomeSearch extends StatefulWidget {
const HomeSearch({
Key? key,
required this.account,
}) : super(key: key);
@override
createState() => _HomeSearchState();
final Account account;
}
class _HomeSearchState extends State<HomeSearch>
with
SelectableItemStreamListMixin<HomeSearch>,
RouteAware,
PageVisibilityMixin {
@override
initState() {
super.initState();
_initBloc();
}
@override
dispose() {
_inputFocus.dispose();
_inputController.dispose();
super.dispose();
}
@override
build(BuildContext context) {
return BlocListener<SearchBloc, SearchBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<SearchBloc, SearchBlocState>(
bloc: _bloc,
builder: (context, state) => Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
secondary: AppTheme.getOverscrollIndicatorColor(context),
),
),
child: _buildContent(context, state),
),
),
);
}
@override
onItemTap(SelectableItem item, int index) {
item.as<PhotoListFileItem>()?.run((fileItem) {
Navigator.pushNamed(
context,
Viewer.routeName,
arguments:
ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex),
);
});
}
void _initBloc() {
if (_bloc.state is! SearchBlocInit) {
// process the current state
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_onStateChange(context, _bloc.state);
});
});
}
}
Widget _buildContent(BuildContext context, SearchBlocState state) {
return WillPopScope(
onWillPop: _onBackButtonPressed,
child: Focus(
focusNode: _stealFocus,
child: Form(
key: _formKey,
child: Stack(
children: [
buildItemStreamListOuter(
context,
child: Stack(
children: [
CustomScrollView(
physics: _isSearchMode
? const NeverScrollableScrollPhysics()
: null,
slivers: [
_buildAppBar(context, state),
if (_isShowLanding(state))
SliverToBoxAdapter(
child: SearchLanding(
account: widget.account,
onFavoritePressed: _onLandingFavoritePressed,
onVideoPressed: _onLandingVideoPressed,
),
)
else if (state is SearchBlocSuccess &&
!_buildItemQueue.isProcessing &&
itemStreamListItems.isEmpty)
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: Text(L10n.global().listNoResultsText),
),
),
)
else
buildItemStreamList(
maxCrossAxisExtent: _thumbSize,
),
SliverToBoxAdapter(
child: SizedBox(
height: _calcBottomAppBarExtent(context),
),
),
],
),
AnimatedVisibility(
opacity: _isSearchMode ? 1 : 0,
duration: k.animationDurationShort,
child: SafeArea(
left: false,
right: false,
bottom: false,
child: Padding(
padding: const EdgeInsets.only(top: kToolbarHeight),
child: Stack(
children: [
GestureDetector(
onTap: () {
if (_isSearchMode) {
setState(() {
_setSearchMode(false);
});
}
},
child: Container(color: Colors.black54),
),
_buildSearchPane(context, state),
],
),
),
),
),
],
),
),
Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (state is SearchBlocLoading ||
_buildItemQueue.isProcessing)
const LinearProgressIndicator(),
SizedBox(
width: double.infinity,
height: _calcBottomAppBarExtent(context),
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
child: const ColoredBox(
color: Colors.transparent,
),
),
),
),
],
),
),
],
),
),
),
);
}
Widget _buildAppBar(BuildContext context, SearchBlocState state) {
if (isSelectionMode) {
return _buildSelectionAppBar(context);
} else {
return _buildNormalAppBar(context, state);
}
}
Widget _buildSelectionAppBar(BuildContext conetxt) {
return SelectionAppBar(
count: selectedListItems.length,
onClosePressed: () {
setState(() {
clearSelectedItems();
});
},
actions: [
IconButton(
icon: const Icon(Icons.share),
tooltip: L10n.global().shareTooltip,
onPressed: () => _onSelectionSharePressed(context),
),
IconButton(
icon: const Icon(Icons.add),
tooltip: L10n.global().addToAlbumTooltip,
onPressed: () => _onSelectionAddToAlbumPressed(context),
),
PopupMenuButton<_SelectionMenuOption>(
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
itemBuilder: (context) => [
PopupMenuItem(
value: _SelectionMenuOption.download,
child: Text(L10n.global().downloadTooltip),
),
PopupMenuItem(
value: _SelectionMenuOption.archive,
child: Text(L10n.global().archiveTooltip),
),
PopupMenuItem(
value: _SelectionMenuOption.delete,
child: Text(L10n.global().deleteTooltip),
),
],
onSelected: (option) => _onSelectionMenuSelected(context, option),
),
],
);
}
Widget _buildNormalAppBar(BuildContext context, SearchBlocState state) {
return SliverAppBar(
automaticallyImplyLeading: false,
floating: true,
snap: true,
title: Focus(
onFocusChange: (hasFocus) {
if (hasFocus && !_isSearchMode) {
setState(() {
_setSearchMode(true);
});
}
},
child: TextFormField(
focusNode: _inputFocus,
controller: _inputController,
decoration: InputDecoration(
hintText: L10n.global().searchTooltip,
),
onFieldSubmitted: (_) {
_onSearchPressed();
},
onSaved: (value) {
_formValue?.input = value ?? "";
},
onChanged: (value) {
_searchSuggestionThrottler.trigger(
maxResponceTime: const Duration(milliseconds: 500),
maxPendingCount: 8,
data: value,
);
},
),
),
actions: [
IconButton(
onPressed: _onSearchPressed,
tooltip: L10n.global().searchTooltip,
icon: const Icon(Icons.search),
),
],
bottom: _isShowLanding(state)
? null
: PreferredSize(
preferredSize: const Size.fromHeight(40),
child: SizedBox(
height: 40,
child: Align(
alignment: AlignmentDirectional.centerStart,
child: _FilterBubbleList(
filters: state.criteria.filters,
onEditPressed: () => _onEditFilterPressed(state),
),
),
),
),
);
}
Widget _buildSearchPane(BuildContext context, SearchBlocState state) {
return Align(
alignment: Alignment.topCenter,
child: ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: SingleChildScrollView(
child: HomeSearchSuggestion(
account: widget.account,
controller: _searchSuggestionController,
),
),
),
);
}
void _onStateChange(BuildContext context, SearchBlocState state) {
if (state is SearchBlocInit) {
itemStreamListItems = [];
} else if (state is SearchBlocSuccess || state is SearchBlocLoading) {
_transformItems(state.items);
} else if (state is SearchBlocFailure) {
_transformItems(state.items);
if (isPageVisible()) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception)),
duration: k.snackBarDurationNormal,
));
}
} else if (state is SearchBlocInconsistent) {
_reqQuery(_activeInput, _activeFilters);
}
}
Future<bool> _onBackButtonPressed() async {
if (_isSearchMode) {
setState(() {
_setSearchMode(false);
});
return false;
} else if (_bloc.state is! SearchBlocInit) {
// back to landing
_reqResetLanding();
setState(() {
_activeInput = "";
_activeFilters = [];
_inputController.text = "";
_searchSuggestionController.search("");
});
return false;
} else {
return true;
}
}
void _onSearchPressed() {
if (_formKey.currentState?.validate() == true) {
_formValue = _FormValue();
_formKey.currentState!.save();
_activeInput = _formValue!.input;
setState(() {
_setSearchMode(false);
});
_reqQuery(_activeInput, _activeFilters);
}
}
void _onLandingFavoritePressed() {
_activeFilters = [
const SearchFavoriteFilter(true),
];
_reqQuery(_activeInput, _activeFilters);
}
void _onLandingVideoPressed() {
_activeFilters = [
const SearchFileTypeFilter(SearchFileType.video),
];
_reqQuery(_activeInput, _activeFilters);
}
Future<void> _onEditFilterPressed(SearchBlocState state) async {
final result = await showDialog<List<SearchFilter>>(
context: context,
builder: (context) => _FilterEditDialog(searchState: state),
);
if (result == null) {
return;
}
_activeFilters = result;
_reqQuery(_activeInput, _activeFilters);
}
void _onSelectionMenuSelected(
BuildContext context, _SelectionMenuOption option) {
switch (option) {
case _SelectionMenuOption.archive:
_onSelectionArchivePressed(context);
break;
case _SelectionMenuOption.delete:
_onSelectionDeletePressed(context);
break;
case _SelectionMenuOption.download:
_onSelectionDownloadPressed();
break;
default:
_log.shout("[_onSelectionMenuSelected] Unknown option: $option");
break;
}
}
void _onSelectionSharePressed(BuildContext context) {
final selected = selectedListItems
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
ShareHandler(
context: context,
clearSelection: () {
setState(() {
clearSelectedItems();
});
},
).shareFiles(widget.account, selected);
}
Future<void> _onSelectionAddToAlbumPressed(BuildContext context) {
return AddSelectionToAlbumHandler()(
context: context,
account: widget.account,
selectedFiles: selectedListItems
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList(),
clearSelection: () {
if (mounted) {
setState(() {
clearSelectedItems();
});
}
},
);
}
void _onSelectionDownloadPressed() {
final selected = selectedListItems
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
DownloadHandler().downloadFiles(widget.account, selected);
setState(() {
clearSelectedItems();
});
}
Future<void> _onSelectionArchivePressed(BuildContext context) async {
final selectedFiles = selectedListItems
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
setState(() {
clearSelectedItems();
});
await ArchiveSelectionHandler(KiwiContainer().resolve<DiContainer>())(
account: widget.account,
selectedFiles: selectedFiles,
);
}
Future<void> _onSelectionDeletePressed(BuildContext context) async {
final selectedFiles = selectedListItems
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
setState(() {
clearSelectedItems();
});
await RemoveSelectionHandler()(
account: widget.account,
selectedFiles: selectedFiles,
isMoveToTrash: true,
);
}
void _transformItems(List<File> files) {
_buildItemQueue.addJob(
PhotoListItemBuilderArguments(
widget.account,
files,
sorter: photoListFileDateTimeSorter,
grouper: PhotoListFileDateGrouper(isMonthOnly: _thumbZoomLevel < 0),
shouldShowFavoriteBadge: true,
locale: language_util.getSelectedLocale() ??
PlatformDispatcher.instance.locale,
),
buildPhotoListItem,
(result) {
if (mounted) {
setState(() {
_backingFiles = result.backingFiles;
itemStreamListItems = result.listItems;
});
}
},
);
}
void _reqQuery(String input, List<SearchFilter> filters) {
final keywords = search_util
.cleanUpSymbols(input)
.split(" ")
.where((s) => s.isNotEmpty)
.map((s) => s.toCi())
.toSet();
_bloc.add(
SearchBlocQuery(widget.account, SearchCriteria(keywords, filters)));
}
void _reqResetLanding() {
_bloc.add(SearchBlocResetLanding(widget.account));
}
void _setSearchMode(bool value) {
_isSearchMode = value;
if (value) {
_inputFocus.requestFocus();
} else {
_inputController.text = _activeInput;
_searchSuggestionController.search(_activeInput);
_stealFocus.requestFocus();
}
}
double _calcBottomAppBarExtent(BuildContext context) => kToolbarHeight;
bool _isShowLanding(SearchBlocState state) => state is SearchBlocInit;
late final _bloc = SearchBloc(KiwiContainer().resolve<DiContainer>());
final _formKey = GlobalKey<FormState>();
_FormValue? _formValue;
final _inputController = TextEditingController();
final _inputFocus = FocusNode();
// used to steal focus from input field
final _stealFocus = FocusNode();
var _isSearchMode = false;
var _activeInput = "";
var _activeFilters = <SearchFilter>[];
final _searchSuggestionController = HomeSearchSuggestionController();
late final _searchSuggestionThrottler = Throttler<String>(
onTriggered: (data) {
_searchSuggestionController.search(data.last);
},
);
final _buildItemQueue =
ComputeQueue<PhotoListItemBuilderArguments, PhotoListItemBuilderResult>();
late final _thumbZoomLevel = Pref().getHomePhotosZoomLevelOr(0);
late final _thumbSize =
photo_list_util.getThumbSize(_thumbZoomLevel).toDouble();
var _backingFiles = <File>[];
static final _log = Logger("widget.home_search._HomeSearchState");
}
class _FormValue {
String input = "";
}
extension on SearchFileType {
String toUserString() {
switch (this) {
case SearchFileType.image:
return L10n.global().searchFilterTypeOptionImageLabel;
case SearchFileType.video:
return L10n.global().searchFilterTypeOptionVideoLabel;
}
}
}
class _FilterBubbleList extends StatelessWidget {
const _FilterBubbleList({
Key? key,
required this.filters,
this.onEditPressed,
}) : super(key: key);
@override
build(BuildContext context) {
return Align(
alignment: AlignmentDirectional.centerStart,
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(width: 16),
...filters
.map((f) => _buildBubble(context, _toUserString(f))),
const SizedBox(width: 8),
],
),
),
),
TextButton(
onPressed: onEditPressed,
child: Text(L10n.global().searchFilterButtonLabel),
),
],
),
);
}
Widget _buildBubble(BuildContext context, String label) {
return Container(
height: 28,
decoration: BoxDecoration(
color: AppTheme.getUnfocusedIconColor(context),
borderRadius: const BorderRadius.all(Radius.circular(14)),
),
padding: const EdgeInsets.symmetric(horizontal: 8),
margin: const EdgeInsets.symmetric(horizontal: 4),
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
fontSize: 12,
color: AppTheme.getPrimaryTextColorInverse(context),
),
),
);
}
String _toUserString(SearchFilter filter) {
if (filter is SearchFileTypeFilter) {
switch (filter.type) {
case SearchFileType.image:
return L10n.global().searchFilterBubbleTypeImageText;
case SearchFileType.video:
return L10n.global().searchFilterBubbleTypeVideoText;
}
} else if (filter is SearchFavoriteFilter) {
return filter.value
? L10n.global().searchFilterBubbleFavoriteTrueText
: L10n.global().searchFilterBubbleFavoriteFalseText;
}
throw ArgumentError.value(filter, "filter");
}
final List<SearchFilter> filters;
final VoidCallback? onEditPressed;
}
class _FilterEditDialog extends StatefulWidget {
const _FilterEditDialog({
Key? key,
required this.searchState,
}) : super(key: key);
@override
createState() => _FilterEditDialogState();
final SearchBlocState searchState;
}
class _FilterEditDialogState extends State<_FilterEditDialog> {
@override
build(BuildContext context) {
return Form(
key: _formKey,
child: AlertDialog(
title: Text(L10n.global().searchFilterDialogTitle),
content: SingleChildScrollView(
child: Column(
children: [
_FilterDropdown<SearchFileType>(
label: L10n.global().searchFilterTypeLabel,
items: SearchFileType.values,
itemStringifier: (item) => item.toUserString(),
initialValue: widget.searchState.criteria.filters
.whereType<SearchFileTypeFilter>()
.firstOrNull
?.type,
onSaved: (value) {
if (value != null) {
_formValue?.filters.add(SearchFileTypeFilter(value));
}
},
),
const SizedBox(height: 8),
_FilterDropdown<bool>(
label: L10n.global().searchFilterFavoriteLabel,
items: const [true, false],
itemStringifier: (item) => item
? L10n.global().searchFilterOptionTrueLabel
: L10n.global().searchFilterOptionFalseLabel,
initialValue: widget.searchState.criteria.filters
.whereType<SearchFavoriteFilter>()
.firstOrNull
?.value,
onSaved: (value) {
if (value != null) {
_formValue?.filters.add(SearchFavoriteFilter(value));
}
},
),
],
),
),
actions: [
TextButton(
onPressed: _onApplyPressed,
child: Text(L10n.global().applyButtonLabel),
),
],
),
);
}
void _onApplyPressed() {
if (_formKey.currentState?.validate() == true) {
_formValue = _FilterEditFormValue();
_formKey.currentState!.save();
Navigator.of(context).pop(_formValue!.filters);
}
}
final _formKey = GlobalKey<FormState>();
_FilterEditFormValue? _formValue;
}
class _FilterEditFormValue {
final filters = <SearchFilter>[];
}
class _FilterDropdown<T> extends StatefulWidget {
const _FilterDropdown({
Key? key,
required this.label,
required this.items,
required this.itemStringifier,
this.initialValue,
this.onValueChanged,
this.onSaved,
}) : super(key: key);
@override
createState() => _FilterDropdownState<T>();
final String label;
final List<T> items;
final String Function(T item) itemStringifier;
final T? initialValue;
final ValueChanged<T?>? onValueChanged;
final FormFieldSetter<T>? onSaved;
}
class _FilterDropdownState<T> extends State<_FilterDropdown<T>> {
@override
initState() {
super.initState();
_value = widget.initialValue;
}
@override
build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
widget.label,
style: TextStyle(
color: AppTheme.getSecondaryTextColor(context),
),
),
),
Expanded(
child: DropdownButtonFormField<T>(
value: _value,
items: [
DropdownMenuItem(
value: null,
child: Text(L10n.global().searchFilterOptionAnyLabel),
),
...widget.items.map((e) => DropdownMenuItem(
value: e,
child: Text(widget.itemStringifier(e)),
)),
],
onChanged: (value) {
setState(() {
_value = value;
});
widget.onValueChanged?.call(_value);
},
onSaved: widget.onSaved,
),
),
],
);
}
T? _value;
}
enum _SelectionMenuOption {
archive,
delete,
download,
}

View file

@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/bloc/home_search_suggestion.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/widget/album_browser.dart';
import 'package:nc_photos/widget/page_visibility_mixin.dart';
import 'package:nc_photos/widget/person_browser.dart';
import 'package:nc_photos/widget/tag_browser.dart';
class HomeSearchSuggestionController {
void search(String phrase) {
_bloc?.add(HomeSearchSuggestionBlocSearch(phrase.toCi()));
}
HomeSearchSuggestionBloc? _bloc;
}
class HomeSearchSuggestion extends StatefulWidget {
const HomeSearchSuggestion({
Key? key,
required this.account,
required this.controller,
}) : super(key: key);
@override
createState() => _HomeSearchSuggestionState();
final Account account;
final HomeSearchSuggestionController controller;
}
class _HomeSearchSuggestionState extends State<HomeSearchSuggestion>
with RouteAware, PageVisibilityMixin {
@override
initState() {
super.initState();
_initBloc();
}
@override
build(BuildContext context) {
return BlocListener<HomeSearchSuggestionBloc,
HomeSearchSuggestionBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child:
BlocBuilder<HomeSearchSuggestionBloc, HomeSearchSuggestionBlocState>(
bloc: _bloc,
builder: (context, state) => Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
secondary: AppTheme.getOverscrollIndicatorColor(context),
),
),
child: _buildContent(context, state),
),
),
);
}
void _initBloc() {
_bloc =
(widget.controller._bloc ??= HomeSearchSuggestionBloc(widget.account));
if (_bloc.state is! HomeSearchSuggestionBlocInit) {
// process the current state
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_onStateChange(context, _bloc.state);
});
});
}
}
Widget _buildContent(
BuildContext context, HomeSearchSuggestionBlocState state) {
if (_items.isEmpty) {
return const SizedBox.shrink();
} else {
return Material(
type: MaterialType.transparency,
child: Column(
mainAxisSize: MainAxisSize.min,
children: _items.map((e) => e.buildWidget(context)).toList(),
),
);
}
}
void _onStateChange(
BuildContext context, HomeSearchSuggestionBlocState state) {
if (state is HomeSearchSuggestionBlocInit) {
_items = [];
} else if (state is HomeSearchSuggestionBlocSuccess ||
state is HomeSearchSuggestionBlocLoading) {
_transformItems(state.results);
} else if (state is HomeSearchSuggestionBlocFailure) {
_transformItems(state.results);
if (isPageVisible()) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception)),
duration: k.snackBarDurationNormal,
));
}
}
}
void _onAlbumPressed(_AlbumListItem item) {
if (mounted) {
Navigator.of(context).pushNamed(AlbumBrowser.routeName,
arguments: AlbumBrowserArguments(widget.account, item.album));
}
}
void _onTagPressed(_TagListItem item) {
if (mounted) {
Navigator.of(context).pushNamed(TagBrowser.routeName,
arguments: TagBrowserArguments(widget.account, item.tag));
}
}
void _onPersonPressed(_PersonListItem item) {
if (mounted) {
Navigator.of(context).pushNamed(PersonBrowser.routeName,
arguments: PersonBrowserArguments(widget.account, item.person));
}
}
void _transformItems(List<HomeSearchResult> results) {
final items = () sync* {
for (final r in results) {
if (r is HomeSearchAlbumResult) {
yield _AlbumListItem(r.album, onTap: _onAlbumPressed);
} else if (r is HomeSearchTagResult) {
yield _TagListItem(r.tag, onTap: _onTagPressed);
} else if (r is HomeSearchPersonResult) {
yield _PersonListItem(r.person, onTap: _onPersonPressed);
} else {
_log.warning("[_transformItems] Unknown type: ${r.runtimeType}");
}
}
}()
.toList();
_items = items;
}
late final HomeSearchSuggestionBloc _bloc;
var _items = <_ListItem>[];
static final _log =
Logger("widget.home_search_suggestion._HomeSearchSuggestionState");
}
abstract class _ListItem {
Widget buildWidget(BuildContext context);
}
class _AlbumListItem implements _ListItem {
const _AlbumListItem(
this.album, {
this.onTap,
});
@override
buildWidget(BuildContext context) => ListTile(
leading: const Icon(Icons.photo_album_outlined),
title: Text(album.name),
onTap: onTap == null ? null : () => onTap!(this),
);
final Album album;
final void Function(_AlbumListItem)? onTap;
}
class _TagListItem implements _ListItem {
const _TagListItem(
this.tag, {
this.onTap,
});
@override
buildWidget(BuildContext context) => ListTile(
leading: const Icon(Icons.local_offer_outlined),
title: Text(tag.displayName),
onTap: onTap == null ? null : () => onTap!(this),
);
final Tag tag;
final void Function(_TagListItem)? onTap;
}
class _PersonListItem implements _ListItem {
const _PersonListItem(
this.person, {
this.onTap,
});
@override
buildWidget(BuildContext context) => ListTile(
leading: const Icon(Icons.person_outline),
title: Text(person.name),
onTap: onTap == null ? null : () => onTap!(this),
);
final Person person;
final void Function(_PersonListItem)? onTap;
}

View file

@ -22,7 +22,6 @@ import 'package:nc_photos/widget/favorite_browser.dart';
import 'package:nc_photos/widget/home.dart';
import 'package:nc_photos/widget/image_editor.dart';
import 'package:nc_photos/widget/local_file_viewer.dart';
import 'package:nc_photos/widget/people_browser.dart';
import 'package:nc_photos/widget/person_browser.dart';
import 'package:nc_photos/widget/root_picker.dart';
import 'package:nc_photos/widget/settings.dart';
@ -34,6 +33,7 @@ import 'package:nc_photos/widget/sign_in.dart';
import 'package:nc_photos/widget/slideshow_viewer.dart';
import 'package:nc_photos/widget/smart_album_browser.dart';
import 'package:nc_photos/widget/splash.dart';
import 'package:nc_photos/widget/tag_browser.dart';
import 'package:nc_photos/widget/trashbin_browser.dart';
import 'package:nc_photos/widget/trashbin_viewer.dart';
import 'package:nc_photos/widget/viewer.dart';
@ -151,7 +151,6 @@ class _MyAppState extends State<MyApp>
route ??= _handleAlbumImporterRoute(settings);
route ??= _handleTrashbinBrowserRoute(settings);
route ??= _handleTrashbinViewerRoute(settings);
route ??= _handlePeopleBrowserRoute(settings);
route ??= _handlePersonBrowserRoute(settings);
route ??= _handleSlideshowViewerRoute(settings);
route ??= _handleSharingBrowserRoute(settings);
@ -167,6 +166,7 @@ class _MyAppState extends State<MyApp>
route ??= _handleEnhancementSettingsRoute(settings);
route ??= _handleImageEditorRoute(settings);
route ??= _handleChangelogRoute(settings);
route ??= _handleTagBrowserRoute(settings);
return route;
}
@ -345,19 +345,6 @@ class _MyAppState extends State<MyApp>
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 &&
@ -572,6 +559,18 @@ class _MyAppState extends State<MyApp>
return null;
}
Route<dynamic>? _handleTagBrowserRoute(RouteSettings settings) {
try {
if (settings.name == TagBrowser.routeName && settings.arguments != null) {
final args = settings.arguments as TagBrowserArguments;
return TagBrowser.buildRoute(args);
}
} catch (e) {
_log.severe("[_handleTagBrowserRoute] Failed while handling route", e);
}
return null;
}
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
final _navigatorKey = GlobalKey<NavigatorState>();

View file

@ -1,337 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/list_person.dart';
import 'package:nc_photos/cache_manager_util.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/exception.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/k.dart' as k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/url_launcher_util.dart';
import 'package:nc_photos/widget/empty_list_indicator.dart';
import 'package:nc_photos/widget/person_browser.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),
);
const PeopleBrowser({
Key? key,
required this.account,
}) : super(key: key);
PeopleBrowser.fromArgs(PeopleBrowserArguments args, {Key? key})
: this(
key: key,
account: args.account,
);
@override
createState() => _PeopleBrowserState();
final Account account;
}
class _PeopleBrowserState extends State<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() {
if (_bloc.state is ListPersonBlocInit) {
_log.info("[_initBloc] Initialize bloc");
} else {
// process the current state
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_onStateChange(context, _bloc.state);
});
});
}
_reqQuery();
}
Widget _buildContent(BuildContext context, ListPersonBlocState state) {
if ((state is ListPersonBlocSuccess || state is ListPersonBlocFailure) &&
_items.isEmpty) {
return Column(
children: [
AppBar(
title: Text(L10n.global().collectionPeopleLabel),
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: const Padding(
padding:
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(
colorScheme: Theme.of(context).colorScheme.copyWith(
secondary: 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)
const Align(
alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(),
),
],
);
}
}
Widget _buildAppBar(BuildContext context) {
return SliverAppBar(
title: Text(L10n.global().collectionPeopleLabel),
floating: true,
);
}
Widget _buildItem(BuildContext context, int index) {
final item = _items[index];
return item.buildWidget(context);
}
void _onStateChange(BuildContext context, ListPersonBlocState state) {
if (state is ListPersonBlocInit) {
_items = [];
} else if (state is ListPersonBlocSuccess ||
state is ListPersonBlocLoading) {
_transformItems(state.items);
} else if (state is ListPersonBlocFailure) {
_transformItems(state.items);
try {
final e = state.exception as ApiException;
if (e.response.statusCode == 404) {
// face recognition app probably not installed, ignore
return;
}
} catch (_) {}
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception)),
duration: k.snackBarDurationNormal,
));
}
}
void _onItemTap(Person person) {
Navigator.pushNamed(context, PersonBrowser.routeName,
arguments: PersonBrowserArguments(widget.account, person));
}
void _transformItems(List<Person> items) {
_items = items
.sorted((a, b) => a.name.compareTo(b.name))
.map((e) => _PersonListItem(
account: widget.account,
name: e.name,
faceUrl: api_util.getFacePreviewUrl(widget.account, e.thumbFaceId,
size: k.faceThumbSize),
onTap: () => _onItemTap(e),
))
.toList();
}
void _reqQuery() {
_bloc.add(ListPersonBlocQuery(widget.account));
}
late final _bloc = ListPersonBloc.of(widget.account);
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(
cacheManager: ThumbnailCacheManager.inst,
imageUrl: faceUrl!,
httpHeaders: {
"Authorization": Api.getAuthorizationHeaderValue(account),
},
fadeInDuration: const Duration(),
filterQuality: FilterQuality.high,
errorWidget: (context, url, error) {
// just leave it empty
return Container();
},
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
),
);
} catch (_) {
cover = Icon(
Icons.person,
color: Colors.white.withOpacity(.8),
size: 64,
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(128),
child: Container(
color: AppTheme.getListItemBackgroundColor(context),
constraints: const BoxConstraints.expand(),
child: cover,
),
);
}
final Account account;
final String name;
final String? faceUrl;
}

View file

@ -0,0 +1,304 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/search_landing.dart';
import 'package:nc_photos/cache_manager_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/help_utils.dart' as help_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/url_launcher_util.dart';
import 'package:nc_photos/widget/person_browser.dart';
class SearchLanding extends StatefulWidget {
const SearchLanding({
Key? key,
required this.account,
this.onFavoritePressed,
this.onVideoPressed,
}) : super(key: key);
@override
createState() => _SearchLandingState();
final Account account;
final VoidCallback? onFavoritePressed;
final VoidCallback? onVideoPressed;
}
class _SearchLandingState extends State<SearchLanding> {
@override
initState() {
super.initState();
_initBloc();
}
@override
build(BuildContext context) {
return BlocListener<SearchLandingBloc, SearchLandingBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<SearchLandingBloc, SearchLandingBlocState>(
bloc: _bloc,
builder: (context, state) => Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
secondary: AppTheme.getOverscrollIndicatorColor(context),
),
),
child: _buildContent(context, state),
),
),
);
}
void _initBloc() {
if (_bloc.state is SearchLandingBlocInit) {
_log.info("[_initBloc] Initialize bloc");
_reqQuery();
} else {
// process the current state
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_onStateChange(context, _bloc.state);
});
});
}
}
Widget _buildContent(BuildContext context, SearchLandingBlocState state) {
return Column(
children: [
if (AccountPref.of(widget.account).isEnableFaceRecognitionAppOr())
..._buildPeopleSection(context, state),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Text(L10n.global().categoriesLabel),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: const Icon(Icons.star_border),
title: Text(L10n.global().collectionFavoritesLabel),
onTap: _onFavoritePressed,
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Divider(height: 1),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
leading: const Icon(Icons.ondemand_video_outlined),
title: Text(L10n.global().searchLandingCategoryVideosLabel),
onTap: _onVideoPressed,
),
],
);
}
List<Widget> _buildPeopleSection(
BuildContext context, SearchLandingBlocState state) {
return [
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Text(L10n.global().collectionPeopleLabel),
trailing: IconButton(
onPressed: () {
launch(help_util.peopleUrl);
},
tooltip: L10n.global().helpTooltip,
icon: const Icon(Icons.help_outline),
),
),
if ((state is SearchLandingBlocSuccess ||
state is SearchLandingBlocFailure) &&
state.persons.isEmpty)
SizedBox(
height: 48,
child: Center(
child: Text(L10n.global().searchLandingPeopleListEmptyText),
),
)
else
SizedBox(
height: 128,
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: state.persons.length,
itemBuilder: (context, i) => _buildItem(context, i),
),
),
];
}
Widget _buildItem(BuildContext context, int index) {
final item = _items[index];
return item.buildWidget(context);
}
void _onStateChange(BuildContext context, SearchLandingBlocState state) {
if (state is SearchLandingBlocInit) {
_items = [];
} else if (state is SearchLandingBlocSuccess ||
state is SearchLandingBlocLoading) {
_transformItems(state.persons);
} else if (state is SearchLandingBlocFailure) {
_transformItems(state.persons);
try {
final e = state.exception as ApiException;
if (e.response.statusCode == 404) {
// face recognition app probably not installed, ignore
return;
}
} catch (_) {}
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception)),
duration: k.snackBarDurationNormal,
));
}
}
void _onFavoritePressed() {
widget.onFavoritePressed?.call();
}
void _onVideoPressed() {
widget.onVideoPressed?.call();
}
void _onItemTap(Person person) {
Navigator.pushNamed(context, PersonBrowser.routeName,
arguments: PersonBrowserArguments(widget.account, person));
}
void _transformItems(List<Person> items) {
_items = items
.sorted((a, b) => a.name.compareTo(b.name))
.map((e) => _LandingPersonItem(
account: widget.account,
name: e.name,
faceUrl: api_util.getFacePreviewUrl(widget.account, e.thumbFaceId,
size: k.faceThumbSize),
onTap: () => _onItemTap(e),
))
.toList();
}
void _reqQuery() {
_bloc.add(SearchLandingBlocQuery(widget.account));
}
late final _bloc = SearchLandingBloc(KiwiContainer().resolve<DiContainer>());
var _items = <_LandingPersonItem>[];
static final _log = Logger("widget.search_landing._SearchLandingState");
}
class _LandingPersonItem {
_LandingPersonItem({
required this.account,
required this.name,
required this.faceUrl,
this.onTap,
});
buildWidget(BuildContext context) {
final content = Padding(
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Center(
child: AspectRatio(
aspectRatio: 1,
child: _buildFaceImage(context),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: 88,
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;
Widget buildPlaceholder() => Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.person,
color: Colors.white.withOpacity(.8),
),
);
try {
cover = FittedBox(
clipBehavior: Clip.hardEdge,
fit: BoxFit.cover,
child: CachedNetworkImage(
cacheManager: ThumbnailCacheManager.inst,
imageUrl: faceUrl!,
httpHeaders: {
"Authorization": Api.getAuthorizationHeaderValue(account),
},
fadeInDuration: const Duration(),
filterQuality: FilterQuality.high,
errorWidget: (context, url, error) => buildPlaceholder(),
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
),
);
} catch (_) {
cover = FittedBox(
child: buildPlaceholder(),
);
}
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;
final VoidCallback? onTap;
}

View file

@ -0,0 +1,458 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/list_tag_file.dart';
import 'package:nc_photos/compute_queue.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/download_handler.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/share_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/throttler.dart';
import 'package:nc_photos/widget/builder/photo_list_item_builder.dart';
import 'package:nc_photos/widget/handler/add_selection_to_album_handler.dart';
import 'package:nc_photos/widget/handler/archive_selection_handler.dart';
import 'package:nc_photos/widget/handler/remove_selection_handler.dart';
import 'package:nc_photos/widget/photo_list_item.dart';
import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util;
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 TagBrowserArguments {
TagBrowserArguments(this.account, this.tag);
final Account account;
final Tag tag;
}
class TagBrowser extends StatefulWidget {
static const routeName = "/tag-browser";
static Route buildRoute(TagBrowserArguments args) => MaterialPageRoute(
builder: (context) => TagBrowser.fromArgs(args),
);
const TagBrowser({
Key? key,
required this.account,
required this.tag,
}) : super(key: key);
TagBrowser.fromArgs(TagBrowserArguments args, {Key? key})
: this(
key: key,
account: args.account,
tag: args.tag,
);
@override
createState() => _TagBrowserState();
final Account account;
final Tag tag;
}
class _TagBrowserState extends State<TagBrowser>
with SelectableItemStreamListMixin<TagBrowser> {
_TagBrowserState() {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
_c = c;
}
static bool require(DiContainer c) => true;
@override
initState() {
super.initState();
_initBloc();
_thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0);
_filePropertyUpdatedListener.begin();
}
@override
dispose() {
_filePropertyUpdatedListener.end();
super.dispose();
}
@override
build(BuildContext context) {
return AppTheme(
child: Scaffold(
body: BlocListener<ListTagFileBloc, ListTagFileBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<ListTagFileBloc, ListTagFileBlocState>(
bloc: _bloc,
builder: (context, state) => _buildContent(context, state),
),
),
),
);
}
@override
onItemTap(SelectableItem item, int index) {
item.as<PhotoListFileItem>()?.run((fileItem) {
Navigator.pushNamed(
context,
Viewer.routeName,
arguments:
ViewerArguments(widget.account, _backingFiles, fileItem.fileIndex),
);
});
}
void _initBloc() {
_log.info("[_initBloc] Initialize bloc");
_reqQuery();
}
Widget _buildContent(BuildContext context, ListTagFileBlocState state) {
return buildItemStreamListOuter(
context,
child: Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
secondary: AppTheme.getOverscrollIndicatorColor(context),
),
),
child: CustomScrollView(
slivers: [
_buildAppBar(context, state),
if (state is ListTagFileBlocLoading || _buildItemQueue.isProcessing)
const SliverToBoxAdapter(
child: Align(
alignment: Alignment.center,
child: LinearProgressIndicator(),
),
),
buildItemStreamList(
maxCrossAxisExtent: _thumbSize.toDouble(),
),
],
),
),
);
}
Widget _buildAppBar(BuildContext context, ListTagFileBlocState state) {
if (isSelectionMode) {
return _buildSelectionAppBar(context);
} else {
return _buildNormalAppBar(context, state);
}
}
Widget _buildNormalAppBar(BuildContext context, ListTagFileBlocState state) {
return SliverAppBar(
floating: true,
titleSpacing: 0,
title: Row(
children: [
const SizedBox(
height: 40,
width: 40,
child: Center(
child: Icon(Icons.local_offer_outlined, size: 24),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.tag.displayName,
style: TextStyle(
color: AppTheme.getPrimaryTextColor(context),
),
maxLines: 1,
softWrap: false,
overflow: TextOverflow.clip,
),
if (state is! ListTagFileBlocLoading &&
!_buildItemQueue.isProcessing)
Text(
L10n.global()
.personPhotoCountText(itemStreamListItems.length),
style: TextStyle(
color: AppTheme.getSecondaryTextColor(context),
fontSize: 12,
),
),
],
),
),
],
),
// ),
actions: [
ZoomMenuButton(
initialZoom: _thumbZoomLevel,
minZoom: 0,
maxZoom: 2,
onZoomChanged: (value) {
setState(() {
_thumbZoomLevel = value.round();
});
Pref().setAlbumBrowserZoomLevel(_thumbZoomLevel);
},
),
],
);
}
Widget _buildSelectionAppBar(BuildContext context) {
return SelectionAppBar(
count: selectedListItems.length,
onClosePressed: () {
setState(() {
clearSelectedItems();
});
},
actions: [
IconButton(
icon: const Icon(Icons.share),
tooltip: L10n.global().shareTooltip,
onPressed: () {
_onSelectionSharePressed(context);
},
),
IconButton(
icon: const Icon(Icons.add),
tooltip: L10n.global().addToAlbumTooltip,
onPressed: () {
_onSelectionAddToAlbumPressed(context);
},
),
PopupMenuButton<_SelectionMenuOption>(
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
itemBuilder: (context) => [
PopupMenuItem(
value: _SelectionMenuOption.download,
child: Text(L10n.global().downloadTooltip),
),
PopupMenuItem(
value: _SelectionMenuOption.archive,
child: Text(L10n.global().archiveTooltip),
),
PopupMenuItem(
value: _SelectionMenuOption.delete,
child: Text(L10n.global().deleteTooltip),
),
],
onSelected: (option) {
_onSelectionMenuSelected(context, option);
},
),
],
);
}
void _onStateChange(BuildContext context, ListTagFileBlocState state) {
if (state is ListTagFileBlocInit) {
itemStreamListItems = [];
} else if (state is ListTagFileBlocSuccess ||
state is ListTagFileBlocLoading) {
_transformItems(state.items);
} else if (state is ListTagFileBlocFailure) {
_transformItems(state.items);
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception)),
duration: k.snackBarDurationNormal,
));
} else if (state is ListTagFileBlocInconsistent) {
_reqQuery();
}
}
void _onSelectionMenuSelected(
BuildContext context, _SelectionMenuOption option) {
switch (option) {
case _SelectionMenuOption.archive:
_onSelectionArchivePressed(context);
break;
case _SelectionMenuOption.delete:
_onSelectionDeletePressed(context);
break;
case _SelectionMenuOption.download:
_onSelectionDownloadPressed();
break;
default:
_log.shout("[_onSelectionMenuSelected] Unknown option: $option");
break;
}
}
void _onSelectionSharePressed(BuildContext context) {
final selected = selectedListItems
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
ShareHandler(
context: context,
clearSelection: () {
setState(() {
clearSelectedItems();
});
},
).shareFiles(widget.account, selected);
}
Future<void> _onSelectionAddToAlbumPressed(BuildContext context) {
return AddSelectionToAlbumHandler()(
context: context,
account: widget.account,
selectedFiles: selectedListItems
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList(),
clearSelection: () {
if (mounted) {
setState(() {
clearSelectedItems();
});
}
},
);
}
void _onSelectionDownloadPressed() {
final selected = selectedListItems
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
DownloadHandler().downloadFiles(widget.account, selected);
setState(() {
clearSelectedItems();
});
}
Future<void> _onSelectionArchivePressed(BuildContext context) async {
final selectedFiles = selectedListItems
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
setState(() {
clearSelectedItems();
});
await ArchiveSelectionHandler(KiwiContainer().resolve<DiContainer>())(
account: widget.account,
selectedFiles: selectedFiles,
);
}
Future<void> _onSelectionDeletePressed(BuildContext context) async {
final selectedFiles = selectedListItems
.whereType<PhotoListFileItem>()
.map((e) => e.file)
.toList();
setState(() {
clearSelectedItems();
});
await RemoveSelectionHandler()(
account: widget.account,
selectedFiles: selectedFiles,
isMoveToTrash: true,
);
}
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,
);
}
Future<void> _transformItems(List<File> files) async {
final PhotoListItemSorter? sorter;
final PhotoListItemGrouper? grouper;
if (Pref().isPhotosTabSortByNameOr()) {
sorter = photoListFilenameSorter;
grouper = null;
} else {
sorter = photoListFileDateTimeSorter;
grouper = PhotoListFileDateGrouper(isMonthOnly: _thumbZoomLevel < 0);
}
_buildItemQueue.addJob(
PhotoListItemBuilderArguments(
widget.account,
files,
sorter: sorter,
grouper: grouper,
shouldShowFavoriteBadge: true,
locale: language_util.getSelectedLocale() ??
PlatformDispatcher.instance.locale,
),
buildPhotoListItem,
(result) {
if (mounted) {
setState(() {
_backingFiles = result.backingFiles;
itemStreamListItems = result.listItems;
});
}
},
);
}
void _reqQuery() {
_bloc.add(ListTagFileBlocQuery(widget.account, widget.tag));
}
late final DiContainer _c;
late final ListTagFileBloc _bloc = ListTagFileBloc(_c);
var _backingFiles = <File>[];
final _buildItemQueue =
ComputeQueue<PhotoListItemBuilderArguments, PhotoListItemBuilderResult>();
var _thumbZoomLevel = 0;
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
late final Throttler _refreshThrottler = Throttler(
onTriggered: (_) {
if (mounted) {
_transformItems(_bloc.state.items);
}
},
logTag: "_TagBrowserState.refresh",
);
late final _filePropertyUpdatedListener =
AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdated);
static final _log = Logger("widget.tag_browser._TagBrowserState");
}
enum _SelectionMenuOption {
archive,
delete,
download,
}