mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-02-24 18:38:48 +01:00
Add search
This commit is contained in:
parent
ad9260385b
commit
7c8dedf259
22 changed files with 3138 additions and 641 deletions
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
}
|
264
app/lib/bloc/home_search_suggestion.dart
Normal file
264
app/lib/bloc/home_search_suggestion.dart
Normal 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;
|
||||
}
|
228
app/lib/bloc/list_tag_file.dart
Normal file
228
app/lib/bloc/list_tag_file.dart
Normal 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
232
app/lib/bloc/search.dart
Normal 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");
|
||||
}
|
99
app/lib/bloc/search_landing.dart
Normal file
99
app/lib/bloc/search_landing.dart
Normal 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");
|
||||
}
|
|
@ -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() =>
|
||||
|
|
94
app/lib/entity/search.dart
Normal file
94
app/lib/entity/search.dart
Normal 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);
|
||||
}
|
53
app/lib/entity/search/data_source.dart
Normal file
53
app/lib/entity/search/data_source.dart
Normal 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");
|
||||
}
|
3
app/lib/entity/search_util.dart
Normal file
3
app/lib/entity/search_util.dart
Normal file
|
@ -0,0 +1,3 @@
|
|||
/// Covert all symbols to whitespace
|
||||
String cleanUpSymbols(String s) =>
|
||||
s.replaceAll(RegExp(r"(?:_|[^\p{L}\d])+", unicode: true), " ");
|
|
@ -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": {
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
18
app/lib/use_case/search.dart
Normal file
18
app/lib/use_case/search.dart
Normal 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;
|
||||
}
|
|
@ -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()],
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
849
app/lib/widget/home_search.dart
Normal file
849
app/lib/widget/home_search.dart
Normal 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,
|
||||
}
|
216
app/lib/widget/home_search_suggestion.dart
Normal file
216
app/lib/widget/home_search_suggestion.dart
Normal 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;
|
||||
}
|
|
@ -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>();
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
304
app/lib/widget/search_landing.dart
Normal file
304
app/lib/widget/search_landing.dart
Normal 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;
|
||||
}
|
458
app/lib/widget/tag_browser.dart
Normal file
458
app/lib/widget/tag_browser.dart
Normal 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,
|
||||
}
|
Loading…
Reference in a new issue