mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-02-02 06:46:22 +01:00
Show places in search page
This commit is contained in:
parent
e860f9767d
commit
88727f1818
12 changed files with 1573 additions and 36 deletions
|
@ -56,6 +56,26 @@ String getFilePreviewUrlRelative(
|
|||
return url;
|
||||
}
|
||||
|
||||
/// Return the preview image URL for [fileId]. See [getFilePreviewUrlRelative]
|
||||
String getFilePreviewUrlByFileId(
|
||||
Account account,
|
||||
int fileId, {
|
||||
required int width,
|
||||
required int height,
|
||||
String? mode,
|
||||
bool? a,
|
||||
}) {
|
||||
String url = "${account.url}/index.php/core/preview?fileId=$fileId";
|
||||
url = "$url&x=$width&y=$height";
|
||||
if (mode != null) {
|
||||
url = "$url&mode=$mode";
|
||||
}
|
||||
if (a != null) {
|
||||
url = "$url&a=${a ? 1 : 0}";
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
String getFileUrl(Account account, File file) {
|
||||
return "${account.url}/${getFileUrlRelative(file)}";
|
||||
}
|
||||
|
|
176
app/lib/bloc/list_location.dart
Normal file
176
app/lib/bloc/list_location.dart
Normal file
|
@ -0,0 +1,176 @@
|
|||
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/event/event.dart';
|
||||
import 'package:nc_photos/throttler.dart';
|
||||
import 'package:nc_photos/use_case/list_location_file.dart';
|
||||
import 'package:nc_photos/use_case/list_location_group.dart';
|
||||
|
||||
abstract class ListLocationBlocEvent {
|
||||
const ListLocationBlocEvent();
|
||||
}
|
||||
|
||||
class ListLocationBlocQuery extends ListLocationBlocEvent {
|
||||
const ListLocationBlocQuery(this.account);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"account: $account, "
|
||||
"}";
|
||||
|
||||
final Account account;
|
||||
}
|
||||
|
||||
/// An external event has happened and may affect the state of this bloc
|
||||
class _ListLocationBlocExternalEvent extends ListLocationBlocEvent {
|
||||
const _ListLocationBlocExternalEvent();
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"}";
|
||||
}
|
||||
|
||||
abstract class ListLocationBlocState {
|
||||
const ListLocationBlocState(this.account, this.result);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"account: $account, "
|
||||
"result: $result, "
|
||||
"}";
|
||||
|
||||
final Account? account;
|
||||
final LocationGroupResult result;
|
||||
}
|
||||
|
||||
class ListLocationBlocInit extends ListLocationBlocState {
|
||||
ListLocationBlocInit()
|
||||
: super(null, const LocationGroupResult([], [], [], []));
|
||||
}
|
||||
|
||||
class ListLocationBlocLoading extends ListLocationBlocState {
|
||||
const ListLocationBlocLoading(Account? account, LocationGroupResult result)
|
||||
: super(account, result);
|
||||
}
|
||||
|
||||
class ListLocationBlocSuccess extends ListLocationBlocState {
|
||||
const ListLocationBlocSuccess(Account? account, LocationGroupResult result)
|
||||
: super(account, result);
|
||||
}
|
||||
|
||||
class ListLocationBlocFailure extends ListLocationBlocState {
|
||||
const ListLocationBlocFailure(
|
||||
Account? account, LocationGroupResult result, this.exception)
|
||||
: super(account, result);
|
||||
|
||||
@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 ListLocationBlocInconsistent extends ListLocationBlocState {
|
||||
const ListLocationBlocInconsistent(
|
||||
Account? account, LocationGroupResult result)
|
||||
: super(account, result);
|
||||
}
|
||||
|
||||
/// List all files associated with a specific tag
|
||||
class ListLocationBloc
|
||||
extends Bloc<ListLocationBlocEvent, ListLocationBlocState> {
|
||||
ListLocationBloc(this._c)
|
||||
: assert(require(_c)),
|
||||
assert(ListLocationFile.require(_c)),
|
||||
super(ListLocationBlocInit()) {
|
||||
_fileRemovedEventListener.begin();
|
||||
|
||||
on<ListLocationBlocEvent>(_onEvent);
|
||||
}
|
||||
|
||||
static bool require(DiContainer c) =>
|
||||
DiContainer.has(c, DiType.taggedFileRepo);
|
||||
|
||||
@override
|
||||
close() {
|
||||
_fileRemovedEventListener.end();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _onEvent(
|
||||
ListLocationBlocEvent event, Emitter<ListLocationBlocState> emit) async {
|
||||
_log.info("[_onEvent] $event");
|
||||
if (event is ListLocationBlocQuery) {
|
||||
await _onEventQuery(event, emit);
|
||||
} else if (event is _ListLocationBlocExternalEvent) {
|
||||
await _onExternalEvent(event, emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onEventQuery(
|
||||
ListLocationBlocQuery ev, Emitter<ListLocationBlocState> emit) async {
|
||||
try {
|
||||
emit(ListLocationBlocLoading(ev.account, state.result));
|
||||
emit(ListLocationBlocSuccess(ev.account, await _query(ev)));
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
|
||||
emit(ListLocationBlocFailure(ev.account, state.result, e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onExternalEvent(_ListLocationBlocExternalEvent ev,
|
||||
Emitter<ListLocationBlocState> emit) async {
|
||||
emit(ListLocationBlocInconsistent(state.account, state.result));
|
||||
}
|
||||
|
||||
void _onFileRemovedEvent(FileRemovedEvent ev) {
|
||||
if (state is ListLocationBlocInit) {
|
||||
// no data in this bloc, ignore
|
||||
return;
|
||||
}
|
||||
if (_isFileOfInterest(ev.file)) {
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 3),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<LocationGroupResult> _query(ListLocationBlocQuery ev) =>
|
||||
ListLocationGroup(_c.withLocalRepo())(ev.account);
|
||||
|
||||
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 _refreshThrottler = Throttler(
|
||||
onTriggered: (_) {
|
||||
add(const _ListLocationBlocExternalEvent());
|
||||
},
|
||||
logTag: "ListLocationBloc.refresh",
|
||||
);
|
||||
|
||||
static final _log = Logger("bloc.list_location.ListLocationBloc");
|
||||
}
|
184
app/lib/bloc/list_location_file.dart
Normal file
184
app/lib/bloc/list_location_file.dart
Normal file
|
@ -0,0 +1,184 @@
|
|||
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/event/event.dart';
|
||||
import 'package:nc_photos/throttler.dart';
|
||||
import 'package:nc_photos/use_case/list_location_file.dart';
|
||||
|
||||
abstract class ListLocationFileBlocEvent {
|
||||
const ListLocationFileBlocEvent();
|
||||
}
|
||||
|
||||
class ListLocationFileBlocQuery extends ListLocationFileBlocEvent {
|
||||
const ListLocationFileBlocQuery(this.account, this.place, this.countryCode);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"account: $account, "
|
||||
"place: $place, "
|
||||
"countryCode: $countryCode, "
|
||||
"}";
|
||||
|
||||
final Account account;
|
||||
final String? place;
|
||||
final String countryCode;
|
||||
}
|
||||
|
||||
/// An external event has happened and may affect the state of this bloc
|
||||
class _ListLocationFileBlocExternalEvent extends ListLocationFileBlocEvent {
|
||||
const _ListLocationFileBlocExternalEvent();
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"}";
|
||||
}
|
||||
|
||||
abstract class ListLocationFileBlocState {
|
||||
const ListLocationFileBlocState(this.account, this.items);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"account: $account, "
|
||||
"items: List {length: ${items.length}}, "
|
||||
"}";
|
||||
|
||||
final Account? account;
|
||||
final List<File> items;
|
||||
}
|
||||
|
||||
class ListLocationFileBlocInit extends ListLocationFileBlocState {
|
||||
ListLocationFileBlocInit() : super(null, const []);
|
||||
}
|
||||
|
||||
class ListLocationFileBlocLoading extends ListLocationFileBlocState {
|
||||
const ListLocationFileBlocLoading(Account? account, List<File> items)
|
||||
: super(account, items);
|
||||
}
|
||||
|
||||
class ListLocationFileBlocSuccess extends ListLocationFileBlocState {
|
||||
const ListLocationFileBlocSuccess(Account? account, List<File> items)
|
||||
: super(account, items);
|
||||
}
|
||||
|
||||
class ListLocationFileBlocFailure extends ListLocationFileBlocState {
|
||||
const ListLocationFileBlocFailure(
|
||||
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 ListLocationFileBlocInconsistent extends ListLocationFileBlocState {
|
||||
const ListLocationFileBlocInconsistent(Account? account, List<File> items)
|
||||
: super(account, items);
|
||||
}
|
||||
|
||||
/// List all files associated with a specific tag
|
||||
class ListLocationFileBloc
|
||||
extends Bloc<ListLocationFileBlocEvent, ListLocationFileBlocState> {
|
||||
ListLocationFileBloc(this._c)
|
||||
: assert(require(_c)),
|
||||
assert(ListLocationFile.require(_c)),
|
||||
super(ListLocationFileBlocInit()) {
|
||||
_fileRemovedEventListener.begin();
|
||||
|
||||
on<ListLocationFileBlocEvent>(_onEvent);
|
||||
}
|
||||
|
||||
static bool require(DiContainer c) =>
|
||||
DiContainer.has(c, DiType.taggedFileRepo);
|
||||
|
||||
@override
|
||||
close() {
|
||||
_fileRemovedEventListener.end();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _onEvent(ListLocationFileBlocEvent event,
|
||||
Emitter<ListLocationFileBlocState> emit) async {
|
||||
_log.info("[_onEvent] $event");
|
||||
if (event is ListLocationFileBlocQuery) {
|
||||
await _onEventQuery(event, emit);
|
||||
} else if (event is _ListLocationFileBlocExternalEvent) {
|
||||
await _onExternalEvent(event, emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onEventQuery(ListLocationFileBlocQuery ev,
|
||||
Emitter<ListLocationFileBlocState> emit) async {
|
||||
try {
|
||||
emit(ListLocationFileBlocLoading(ev.account, state.items));
|
||||
emit(ListLocationFileBlocSuccess(ev.account, await _query(ev)));
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
|
||||
emit(ListLocationFileBlocFailure(ev.account, state.items, e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onExternalEvent(_ListLocationFileBlocExternalEvent ev,
|
||||
Emitter<ListLocationFileBlocState> emit) async {
|
||||
emit(ListLocationFileBlocInconsistent(state.account, state.items));
|
||||
}
|
||||
|
||||
void _onFileRemovedEvent(FileRemovedEvent ev) {
|
||||
if (state is ListLocationFileBlocInit) {
|
||||
// no data in this bloc, ignore
|
||||
return;
|
||||
}
|
||||
if (_isFileOfInterest(ev.file)) {
|
||||
_refreshThrottler.trigger(
|
||||
maxResponceTime: const Duration(seconds: 3),
|
||||
maxPendingCount: 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<File>> _query(ListLocationFileBlocQuery ev) async {
|
||||
final files = <File>[];
|
||||
for (final r in ev.account.roots) {
|
||||
final dir = File(path: file_util.unstripPath(ev.account, r));
|
||||
files.addAll(await ListLocationFile(_c)(
|
||||
ev.account, dir, ev.place, ev.countryCode));
|
||||
}
|
||||
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 _refreshThrottler = Throttler(
|
||||
onTriggered: (_) {
|
||||
add(const _ListLocationFileBlocExternalEvent());
|
||||
},
|
||||
logTag: "ListLocationFileBloc.refresh",
|
||||
);
|
||||
|
||||
static final _log = Logger("bloc.list_location_file.ListLocationFileBloc");
|
||||
}
|
|
@ -3,6 +3,7 @@ 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_location_group.dart';
|
||||
import 'package:nc_photos/use_case/list_person.dart';
|
||||
|
||||
abstract class SearchLandingBlocEvent {
|
||||
|
@ -21,36 +22,41 @@ class SearchLandingBlocQuery extends SearchLandingBlocEvent {
|
|||
}
|
||||
|
||||
abstract class SearchLandingBlocState {
|
||||
const SearchLandingBlocState(this.account, this.persons);
|
||||
const SearchLandingBlocState(this.account, this.persons, this.locations);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"account: $account, "
|
||||
"persons: List {length: ${persons.length}}, "
|
||||
"locations: $locations, "
|
||||
"}";
|
||||
|
||||
final Account? account;
|
||||
final List<Person> persons;
|
||||
final LocationGroupResult locations;
|
||||
}
|
||||
|
||||
class SearchLandingBlocInit extends SearchLandingBlocState {
|
||||
SearchLandingBlocInit() : super(null, const []);
|
||||
SearchLandingBlocInit()
|
||||
: super(null, const [], const LocationGroupResult([], [], [], []));
|
||||
}
|
||||
|
||||
class SearchLandingBlocLoading extends SearchLandingBlocState {
|
||||
const SearchLandingBlocLoading(Account? account, List<Person> persons)
|
||||
: super(account, persons);
|
||||
const SearchLandingBlocLoading(
|
||||
Account? account, List<Person> persons, LocationGroupResult locations)
|
||||
: super(account, persons, locations);
|
||||
}
|
||||
|
||||
class SearchLandingBlocSuccess extends SearchLandingBlocState {
|
||||
const SearchLandingBlocSuccess(Account? account, List<Person> persons)
|
||||
: super(account, persons);
|
||||
const SearchLandingBlocSuccess(
|
||||
Account? account, List<Person> persons, LocationGroupResult locations)
|
||||
: super(account, persons, locations);
|
||||
}
|
||||
|
||||
class SearchLandingBlocFailure extends SearchLandingBlocState {
|
||||
const SearchLandingBlocFailure(
|
||||
Account? account, List<Person> persons, this.exception)
|
||||
: super(account, persons);
|
||||
const SearchLandingBlocFailure(Account? account, List<Person> persons,
|
||||
LocationGroupResult locations, this.exception)
|
||||
: super(account, persons, locations);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
|
@ -83,17 +89,39 @@ class SearchLandingBloc
|
|||
Future<void> _onEventQuery(
|
||||
SearchLandingBlocQuery ev, Emitter<SearchLandingBlocState> emit) async {
|
||||
try {
|
||||
emit(SearchLandingBlocLoading(ev.account, state.persons));
|
||||
emit(SearchLandingBlocSuccess(ev.account, await _query(ev)));
|
||||
emit(
|
||||
SearchLandingBlocLoading(ev.account, state.persons, state.locations));
|
||||
|
||||
List<Person>? persons;
|
||||
try {
|
||||
persons = await _queryPeople(ev);
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout("[_onEventQuery] Failed while _queryPeople", e, stackTrace);
|
||||
}
|
||||
|
||||
LocationGroupResult? locations;
|
||||
try {
|
||||
locations = await _queryLocations(ev);
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_onEventQuery] Failed while _queryLocations", e, stackTrace);
|
||||
}
|
||||
|
||||
emit(SearchLandingBlocSuccess(ev.account, persons ?? [],
|
||||
locations ?? const LocationGroupResult([], [], [], [])));
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
|
||||
emit(SearchLandingBlocFailure(ev.account, state.persons, e));
|
||||
emit(SearchLandingBlocFailure(
|
||||
ev.account, state.persons, state.locations, e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Person>> _query(SearchLandingBlocQuery ev) =>
|
||||
Future<List<Person>> _queryPeople(SearchLandingBlocQuery ev) =>
|
||||
ListPerson(_c.withLocalRepo())(ev.account);
|
||||
|
||||
Future<LocationGroupResult> _queryLocations(SearchLandingBlocQuery ev) =>
|
||||
ListLocationGroup(_c.withLocalRepo())(ev.account);
|
||||
|
||||
final DiContainer _c;
|
||||
|
||||
static final _log = Logger("bloc.search_landing.SearchLandingBloc");
|
||||
|
|
|
@ -1389,6 +1389,10 @@
|
|||
"@gpsPlaceAboutDialogContent": {
|
||||
"description": "Warn about the inaccurate nature of our offline reverse geocoding feature (i.e., converting coordinates into addresses)"
|
||||
},
|
||||
"collectionPlacesLabel": "Places",
|
||||
"@collectionPlacesLabel": {
|
||||
"description": "Browse photos grouped by place"
|
||||
},
|
||||
|
||||
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
|
||||
"@errorUnauthenticated": {
|
||||
|
|
|
@ -146,6 +146,7 @@
|
|||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent",
|
||||
"collectionPlacesLabel",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -310,6 +311,7 @@
|
|||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent",
|
||||
"collectionPlacesLabel",
|
||||
"errorAlbumDowngrade"
|
||||
],
|
||||
|
||||
|
@ -354,7 +356,8 @@
|
|||
"showAllButtonLabel",
|
||||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent"
|
||||
"gpsPlaceAboutDialogContent",
|
||||
"collectionPlacesLabel"
|
||||
],
|
||||
|
||||
"es": [
|
||||
|
@ -363,13 +366,15 @@
|
|||
"showAllButtonLabel",
|
||||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent"
|
||||
"gpsPlaceAboutDialogContent",
|
||||
"collectionPlacesLabel"
|
||||
],
|
||||
|
||||
"fi": [
|
||||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent"
|
||||
"gpsPlaceAboutDialogContent",
|
||||
"collectionPlacesLabel"
|
||||
],
|
||||
|
||||
"fr": [
|
||||
|
@ -438,7 +443,8 @@
|
|||
"showAllButtonLabel",
|
||||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent"
|
||||
"gpsPlaceAboutDialogContent",
|
||||
"collectionPlacesLabel"
|
||||
],
|
||||
|
||||
"pl": [
|
||||
|
@ -524,7 +530,8 @@
|
|||
"showAllButtonLabel",
|
||||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent"
|
||||
"gpsPlaceAboutDialogContent",
|
||||
"collectionPlacesLabel"
|
||||
],
|
||||
|
||||
"pt": [
|
||||
|
@ -589,7 +596,8 @@
|
|||
"showAllButtonLabel",
|
||||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent"
|
||||
"gpsPlaceAboutDialogContent",
|
||||
"collectionPlacesLabel"
|
||||
],
|
||||
|
||||
"ru": [
|
||||
|
@ -654,7 +662,8 @@
|
|||
"showAllButtonLabel",
|
||||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent"
|
||||
"gpsPlaceAboutDialogContent",
|
||||
"collectionPlacesLabel"
|
||||
],
|
||||
|
||||
"zh": [
|
||||
|
@ -719,7 +728,8 @@
|
|||
"showAllButtonLabel",
|
||||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent"
|
||||
"gpsPlaceAboutDialogContent",
|
||||
"collectionPlacesLabel"
|
||||
],
|
||||
|
||||
"zh_Hant": [
|
||||
|
@ -784,6 +794,7 @@
|
|||
"showAllButtonLabel",
|
||||
"gpsPlaceText",
|
||||
"gpsPlaceAboutDialogTitle",
|
||||
"gpsPlaceAboutDialogContent"
|
||||
"gpsPlaceAboutDialogContent",
|
||||
"collectionPlacesLabel"
|
||||
]
|
||||
}
|
||||
|
|
58
app/lib/use_case/list_location_file.dart
Normal file
58
app/lib/use_case/list_location_file.dart
Normal file
|
@ -0,0 +1,58 @@
|
|||
import 'package:drift/drift.dart' as sql;
|
||||
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/sqlite_table_converter.dart';
|
||||
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
|
||||
import 'package:nc_photos/location_util.dart' as location_util;
|
||||
import 'package:nc_photos/object_extension.dart';
|
||||
|
||||
class ListLocationFile {
|
||||
ListLocationFile(this._c) : assert(require(_c));
|
||||
|
||||
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
|
||||
|
||||
/// List all files located in [place], [countryCode]
|
||||
Future<List<File>> call(
|
||||
Account account, File dir, String? place, String countryCode) async {
|
||||
final dbFiles = await _c.sqliteDb.use((db) async {
|
||||
final query = db.queryFiles().run((q) {
|
||||
q
|
||||
..setQueryMode(sql.FilesQueryMode.completeFile)
|
||||
..setAppAccount(account);
|
||||
dir.strippedPathWithEmpty.run((p) {
|
||||
if (p.isNotEmpty) {
|
||||
q.byOrRelativePathPattern("$p/%");
|
||||
}
|
||||
});
|
||||
return q.build();
|
||||
});
|
||||
if (place == null ||
|
||||
location_util.alpha2CodeToName(countryCode) == place) {
|
||||
// some places in the DB have the same name as the country, in such
|
||||
// cases, we return all photos from the country
|
||||
query.where(db.imageLocations.countryCode.equals(countryCode));
|
||||
} else {
|
||||
query
|
||||
..where(db.imageLocations.name.equals(place) |
|
||||
db.imageLocations.admin1.equals(place) |
|
||||
db.imageLocations.admin2.equals(place))
|
||||
..where(db.imageLocations.countryCode.equals(countryCode));
|
||||
}
|
||||
return await query
|
||||
.map((r) => sql.CompleteFile(
|
||||
r.readTable(db.files),
|
||||
r.readTable(db.accountFiles),
|
||||
r.readTableOrNull(db.images),
|
||||
r.readTableOrNull(db.imageLocations),
|
||||
r.readTableOrNull(db.trashes),
|
||||
))
|
||||
.get();
|
||||
});
|
||||
return dbFiles
|
||||
.map((f) => SqliteFileConverter.fromSql(account.userId.toString(), f))
|
||||
.toList();
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
}
|
171
app/lib/use_case/list_location_group.dart
Normal file
171
app/lib/use_case/list_location_group.dart
Normal file
|
@ -0,0 +1,171 @@
|
|||
import 'package:drift/drift.dart' as sql;
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/di_container.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/location_util.dart' as location_util;
|
||||
|
||||
class LocationGroup {
|
||||
const LocationGroup(
|
||||
this.place, this.countryCode, this.latest, this.latestFileId);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"place: $place, "
|
||||
"countryCode: $countryCode, "
|
||||
"latest: $latest, "
|
||||
"latestFileId: $latestFileId, "
|
||||
"}";
|
||||
|
||||
final String place;
|
||||
final String countryCode;
|
||||
final DateTime latest;
|
||||
final int latestFileId;
|
||||
}
|
||||
|
||||
class LocationGroupResult {
|
||||
const LocationGroupResult(
|
||||
this.name, this.admin1, this.admin2, this.countryCode);
|
||||
|
||||
@override
|
||||
toString() => "$runtimeType {"
|
||||
"name: List {length: ${name.length}}, "
|
||||
"admin1: List {length: ${admin1.length}}, "
|
||||
"admin2: List {length: ${admin2.length}}, "
|
||||
"countryCode: List {length: ${countryCode.length}}, "
|
||||
"}";
|
||||
|
||||
final List<LocationGroup> name;
|
||||
final List<LocationGroup> admin1;
|
||||
final List<LocationGroup> admin2;
|
||||
final List<LocationGroup> countryCode;
|
||||
}
|
||||
|
||||
class ListLocationGroup {
|
||||
ListLocationGroup(this._c) : assert(require(_c));
|
||||
|
||||
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
|
||||
|
||||
/// List location groups based on the name of the places
|
||||
Future<LocationGroupResult> call(Account account) async {
|
||||
final s = Stopwatch()..start();
|
||||
try {
|
||||
return await _c.sqliteDb.use((db) async {
|
||||
final dbAccount = await db.accountOf(account);
|
||||
|
||||
final nameResult = <LocationGroup>[];
|
||||
final admin1Result = <LocationGroup>[];
|
||||
final admin2Result = <LocationGroup>[];
|
||||
final countryCodeResult = <LocationGroup>[];
|
||||
for (final r in account.roots) {
|
||||
final latest = db.accountFiles.bestDateTime.max();
|
||||
final nameQ =
|
||||
_buildQuery(db, dbAccount, r, latest, db.imageLocations.name);
|
||||
try {
|
||||
nameResult.addAll(await nameQ
|
||||
.map((r) => LocationGroup(
|
||||
r.read(db.imageLocations.name)!,
|
||||
r.read(db.imageLocations.countryCode)!,
|
||||
r.read(latest),
|
||||
r.read(db.files.fileId)!,
|
||||
))
|
||||
.get());
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout("[call] Failed while query name group", e, stackTrace);
|
||||
}
|
||||
|
||||
final admin1Q =
|
||||
_buildQuery(db, dbAccount, r, latest, db.imageLocations.admin1);
|
||||
try {
|
||||
admin1Result.addAll(await admin1Q
|
||||
.map((r) => LocationGroup(
|
||||
r.read(db.imageLocations.admin1)!,
|
||||
r.read(db.imageLocations.countryCode)!,
|
||||
r.read(latest),
|
||||
r.read(db.files.fileId)!,
|
||||
))
|
||||
.get());
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout("[call] Failed while query admin1 group", e, stackTrace);
|
||||
}
|
||||
|
||||
final admin2Q =
|
||||
_buildQuery(db, dbAccount, r, latest, db.imageLocations.admin2);
|
||||
try {
|
||||
admin2Result.addAll(await admin2Q
|
||||
.map((r) => LocationGroup(
|
||||
r.read(db.imageLocations.admin2)!,
|
||||
r.read(db.imageLocations.countryCode)!,
|
||||
r.read(latest),
|
||||
r.read(db.files.fileId)!,
|
||||
))
|
||||
.get());
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout("[call] Failed while query admin2 group", e, stackTrace);
|
||||
}
|
||||
|
||||
final countryCodeQ = _buildQuery(
|
||||
db, dbAccount, r, latest, db.imageLocations.countryCode);
|
||||
try {
|
||||
countryCodeResult.addAll(await countryCodeQ.map((r) {
|
||||
final cc = r.read(db.imageLocations.countryCode)!;
|
||||
return LocationGroup(location_util.alpha2CodeToName(cc) ?? cc, cc,
|
||||
r.read(latest), r.read(db.files.fileId)!);
|
||||
}).get());
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[call] Failed while query countryCode group", e, stackTrace);
|
||||
}
|
||||
}
|
||||
return LocationGroupResult(
|
||||
nameResult, admin1Result, admin2Result, countryCodeResult);
|
||||
});
|
||||
} finally {
|
||||
_log.info("[call] Elapsed time: ${s.elapsedMilliseconds}ms");
|
||||
}
|
||||
}
|
||||
|
||||
sql.JoinedSelectStatement _buildQuery(
|
||||
sql.SqliteDb db,
|
||||
sql.Account dbAccount,
|
||||
String dir,
|
||||
sql.Expression<DateTime> latest,
|
||||
sql.GeneratedColumn<String?> groupColumn,
|
||||
) {
|
||||
final query = db.selectOnly(db.imageLocations).join([
|
||||
sql.innerJoin(db.accountFiles,
|
||||
db.accountFiles.rowId.equalsExp(db.imageLocations.accountFile),
|
||||
useColumns: false),
|
||||
sql.innerJoin(db.files, db.files.rowId.equalsExp(db.accountFiles.file),
|
||||
useColumns: false),
|
||||
]);
|
||||
if (identical(groupColumn, db.imageLocations.countryCode)) {
|
||||
query
|
||||
..addColumns([db.imageLocations.countryCode, latest, db.files.fileId])
|
||||
..groupBy([db.imageLocations.countryCode],
|
||||
having: db.accountFiles.bestDateTime.equalsExp(latest));
|
||||
} else {
|
||||
query
|
||||
..addColumns([
|
||||
groupColumn,
|
||||
db.imageLocations.countryCode,
|
||||
latest,
|
||||
db.files.fileId
|
||||
])
|
||||
..groupBy([groupColumn, db.imageLocations.countryCode],
|
||||
having: db.accountFiles.bestDateTime.equalsExp(latest));
|
||||
}
|
||||
query
|
||||
..where(db.accountFiles.account.equals(dbAccount.rowId))
|
||||
..where(groupColumn.isNotNull());
|
||||
if (dir.isNotEmpty) {
|
||||
query.where(db.accountFiles.relativePath.like("$dir/%"));
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
|
||||
static final _log = Logger("use_case.list_location_group.ListLocationGroup");
|
||||
}
|
|
@ -23,6 +23,8 @@ 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/place_browser.dart';
|
||||
import 'package:nc_photos/widget/places_browser.dart';
|
||||
import 'package:nc_photos/widget/root_picker.dart';
|
||||
import 'package:nc_photos/widget/settings.dart';
|
||||
import 'package:nc_photos/widget/setup.dart';
|
||||
|
@ -167,6 +169,8 @@ class _MyAppState extends State<MyApp>
|
|||
route ??= _handleChangelogRoute(settings);
|
||||
route ??= _handleTagBrowserRoute(settings);
|
||||
route ??= _handlePeopleBrowserRoute(settings);
|
||||
route ??= _handlePlaceBrowserRoute(settings);
|
||||
route ??= _handlePlacesBrowserRoute(settings);
|
||||
return route;
|
||||
}
|
||||
|
||||
|
@ -570,6 +574,32 @@ class _MyAppState extends State<MyApp>
|
|||
return null;
|
||||
}
|
||||
|
||||
Route<dynamic>? _handlePlaceBrowserRoute(RouteSettings settings) {
|
||||
try {
|
||||
if (settings.name == PlaceBrowser.routeName &&
|
||||
settings.arguments != null) {
|
||||
final args = settings.arguments as PlaceBrowserArguments;
|
||||
return PlaceBrowser.buildRoute(args);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe("[_handlePlaceBrowserRoute] Failed while handling route", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Route<dynamic>? _handlePlacesBrowserRoute(RouteSettings settings) {
|
||||
try {
|
||||
if (settings.name == PlacesBrowser.routeName &&
|
||||
settings.arguments != null) {
|
||||
final args = settings.arguments as PlacesBrowserArguments;
|
||||
return PlacesBrowser.buildRoute(args);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.severe("[_handlePlacesBrowserRoute] Failed while handling route", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
final _navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
|
|
416
app/lib/widget/place_browser.dart
Normal file
416
app/lib/widget/place_browser.dart
Normal file
|
@ -0,0 +1,416 @@
|
|||
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_location_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/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/location_util.dart' as location_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/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 PlaceBrowserArguments {
|
||||
const PlaceBrowserArguments(this.account, this.place, this.countryCode);
|
||||
|
||||
final Account account;
|
||||
final String? place;
|
||||
final String countryCode;
|
||||
}
|
||||
|
||||
class PlaceBrowser extends StatefulWidget {
|
||||
static const routeName = "/place-browser";
|
||||
|
||||
static Route buildRoute(PlaceBrowserArguments args) => MaterialPageRoute(
|
||||
builder: (context) => PlaceBrowser.fromArgs(args),
|
||||
);
|
||||
|
||||
const PlaceBrowser({
|
||||
Key? key,
|
||||
required this.account,
|
||||
required this.place,
|
||||
required this.countryCode,
|
||||
}) : super(key: key);
|
||||
|
||||
PlaceBrowser.fromArgs(PlaceBrowserArguments args, {Key? key})
|
||||
: this(
|
||||
key: key,
|
||||
account: args.account,
|
||||
place: args.place,
|
||||
countryCode: args.countryCode,
|
||||
);
|
||||
|
||||
@override
|
||||
createState() => _PlaceBrowserState();
|
||||
|
||||
final Account account;
|
||||
final String? place;
|
||||
final String countryCode;
|
||||
}
|
||||
|
||||
class _PlaceBrowserState extends State<PlaceBrowser>
|
||||
with SelectableItemStreamListMixin<PlaceBrowser> {
|
||||
_PlaceBrowserState() {
|
||||
final c = KiwiContainer().resolve<DiContainer>();
|
||||
assert(require(c));
|
||||
assert(ListLocationFileBloc.require(c));
|
||||
_c = c;
|
||||
}
|
||||
|
||||
static bool require(DiContainer c) => true;
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_initBloc();
|
||||
_thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0);
|
||||
}
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return AppTheme(
|
||||
child: Scaffold(
|
||||
body: BlocListener<ListLocationFileBloc, ListLocationFileBlocState>(
|
||||
bloc: _bloc,
|
||||
listener: (context, state) => _onStateChange(context, state),
|
||||
child: BlocBuilder<ListLocationFileBloc, ListLocationFileBlocState>(
|
||||
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, ListLocationFileBlocState 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 ListLocationFileBlocLoading ||
|
||||
_buildItemQueue.isProcessing)
|
||||
const SliverToBoxAdapter(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: LinearProgressIndicator(),
|
||||
),
|
||||
),
|
||||
buildItemStreamList(
|
||||
maxCrossAxisExtent: _thumbSize.toDouble(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ListLocationFileBlocState state) {
|
||||
if (isSelectionMode) {
|
||||
return _buildSelectionAppBar(context);
|
||||
} else {
|
||||
return _buildNormalAppBar(context, state);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildNormalAppBar(
|
||||
BuildContext context, ListLocationFileBlocState state) {
|
||||
return SliverAppBar(
|
||||
floating: true,
|
||||
titleSpacing: 0,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.place ?? location_util.alpha2CodeToName(widget.countryCode)!,
|
||||
style: TextStyle(
|
||||
color: AppTheme.getPrimaryTextColor(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
if (state is! ListLocationFileBlocLoading &&
|
||||
!_buildItemQueue.isProcessing)
|
||||
Text(
|
||||
L10n.global().personPhotoCountText(_backingFiles.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, ListLocationFileBlocState state) {
|
||||
if (state is ListLocationFileBlocInit) {
|
||||
itemStreamListItems = [];
|
||||
} else if (state is ListLocationFileBlocSuccess ||
|
||||
state is ListLocationFileBlocLoading) {
|
||||
_transformItems(state.items);
|
||||
} else if (state is ListLocationFileBlocFailure) {
|
||||
_transformItems(state.items);
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(exception_util.toUserString(state.exception)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
} else if (state is ListLocationFileBlocInconsistent) {
|
||||
_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,
|
||||
);
|
||||
}
|
||||
|
||||
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(ListLocationFileBlocQuery(
|
||||
widget.account, widget.place, widget.countryCode));
|
||||
}
|
||||
|
||||
late final DiContainer _c;
|
||||
|
||||
late final ListLocationFileBloc _bloc = ListLocationFileBloc(_c);
|
||||
var _backingFiles = <File>[];
|
||||
|
||||
final _buildItemQueue =
|
||||
ComputeQueue<PhotoListItemBuilderArguments, PhotoListItemBuilderResult>();
|
||||
|
||||
var _thumbZoomLevel = 0;
|
||||
int get _thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
|
||||
|
||||
static final _log = Logger("widget.place_browser._PlaceBrowserState");
|
||||
}
|
||||
|
||||
enum _SelectionMenuOption {
|
||||
archive,
|
||||
delete,
|
||||
download,
|
||||
}
|
337
app/lib/widget/places_browser.dart
Normal file
337
app/lib/widget/places_browser.dart
Normal file
|
@ -0,0 +1,337 @@
|
|||
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: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/list_location.dart';
|
||||
import 'package:nc_photos/cache_manager_util.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/exception.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/use_case/list_location_group.dart';
|
||||
import 'package:nc_photos/widget/collection_list_item.dart';
|
||||
import 'package:nc_photos/widget/place_browser.dart';
|
||||
|
||||
class PlacesBrowserArguments {
|
||||
const PlacesBrowserArguments(this.account);
|
||||
|
||||
final Account account;
|
||||
}
|
||||
|
||||
/// Show a list of all people associated with this account
|
||||
class PlacesBrowser extends StatefulWidget {
|
||||
static const routeName = "/places-browser";
|
||||
|
||||
static Route buildRoute(PlacesBrowserArguments args) => MaterialPageRoute(
|
||||
builder: (context) => PlacesBrowser.fromArgs(args),
|
||||
);
|
||||
|
||||
const PlacesBrowser({
|
||||
Key? key,
|
||||
required this.account,
|
||||
}) : super(key: key);
|
||||
|
||||
PlacesBrowser.fromArgs(PlacesBrowserArguments args, {Key? key})
|
||||
: this(
|
||||
key: key,
|
||||
account: args.account,
|
||||
);
|
||||
|
||||
@override
|
||||
createState() => _PlacesBrowserState();
|
||||
|
||||
final Account account;
|
||||
}
|
||||
|
||||
class _PlacesBrowserState extends State<PlacesBrowser> {
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_initBloc();
|
||||
}
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return AppTheme(
|
||||
child: Scaffold(
|
||||
body: BlocListener<ListLocationBloc, ListLocationBlocState>(
|
||||
bloc: _bloc,
|
||||
listener: (context, state) => _onStateChange(context, state),
|
||||
child: BlocBuilder<ListLocationBloc, ListLocationBlocState>(
|
||||
bloc: _bloc,
|
||||
builder: (context, state) => _buildContent(context, state),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initBloc() {
|
||||
if (_bloc.state is ListLocationBlocInit) {
|
||||
_log.info("[_initBloc] Initialize bloc");
|
||||
} else {
|
||||
// process the current state
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
_onStateChange(context, _bloc.state);
|
||||
});
|
||||
});
|
||||
}
|
||||
_reqQuery();
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, ListLocationBlocState state) {
|
||||
return Stack(
|
||||
children: [
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: Theme.of(context).colorScheme.copyWith(
|
||||
secondary: AppTheme.getOverscrollIndicatorColor(context),
|
||||
),
|
||||
),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context),
|
||||
if (state is ListLocationBlocLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: LinearProgressIndicator(),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: _countryItems.length,
|
||||
itemBuilder: (context, i) =>
|
||||
_countryItems[i].buildWidget(context),
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 8),
|
||||
),
|
||||
SliverStaggeredGrid.extentBuilder(
|
||||
maxCrossAxisExtent: 160,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
itemCount: _placeItems.length,
|
||||
itemBuilder: (context, i) =>
|
||||
_placeItems[i].buildWidget(context),
|
||||
staggeredTileBuilder: (_) => const StaggeredTile.count(1, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
return SliverAppBar(
|
||||
title: Text(L10n.global().collectionPlacesLabel),
|
||||
floating: true,
|
||||
);
|
||||
}
|
||||
|
||||
void _onStateChange(BuildContext context, ListLocationBlocState state) {
|
||||
if (state is ListLocationBlocInit) {
|
||||
_placeItems = [];
|
||||
_countryItems = [];
|
||||
} else if (state is ListLocationBlocSuccess ||
|
||||
state is ListLocationBlocLoading) {
|
||||
_transformItems(state.result);
|
||||
} else if (state is ListLocationBlocFailure) {
|
||||
_transformItems(state.result);
|
||||
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 _onPlaceTap(LocationGroup location) {
|
||||
Navigator.pushNamed(context, PlaceBrowser.routeName,
|
||||
arguments: PlaceBrowserArguments(
|
||||
widget.account, location.place, location.countryCode));
|
||||
}
|
||||
|
||||
void _onCountryTap(LocationGroup location) {
|
||||
Navigator.pushNamed(context, PlaceBrowser.routeName,
|
||||
arguments:
|
||||
PlaceBrowserArguments(widget.account, null, location.countryCode));
|
||||
}
|
||||
|
||||
void _transformItems(LocationGroupResult? result) {
|
||||
if (result == null) {
|
||||
_placeItems = [];
|
||||
_countryItems = [];
|
||||
return;
|
||||
}
|
||||
|
||||
int sorter(LocationGroup a, LocationGroup b) {
|
||||
final latestCompare = b.latest.compareTo(a.latest);
|
||||
if (latestCompare == 0) {
|
||||
return a.place.compareTo(b.place);
|
||||
} else {
|
||||
return latestCompare;
|
||||
}
|
||||
}
|
||||
|
||||
_placeItems = result.name
|
||||
.sorted(sorter)
|
||||
.map((e) => _PlaceItem(
|
||||
account: widget.account,
|
||||
place: e.place,
|
||||
thumbUrl: api_util.getFilePreviewUrlByFileId(
|
||||
widget.account,
|
||||
e.latestFileId,
|
||||
width: k.photoThumbSize,
|
||||
height: k.photoThumbSize,
|
||||
),
|
||||
onTap: () => _onPlaceTap(e),
|
||||
))
|
||||
.toList();
|
||||
_countryItems = result.countryCode
|
||||
.sorted(sorter)
|
||||
.map((e) => _CountryItem(
|
||||
account: widget.account,
|
||||
country: e.place,
|
||||
thumbUrl: api_util.getFilePreviewUrlByFileId(
|
||||
widget.account,
|
||||
e.latestFileId,
|
||||
width: k.photoThumbSize,
|
||||
height: k.photoThumbSize,
|
||||
),
|
||||
onTap: () => _onCountryTap(e),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _reqQuery() {
|
||||
_bloc.add(ListLocationBlocQuery(widget.account));
|
||||
}
|
||||
|
||||
late final _bloc = ListLocationBloc(KiwiContainer().resolve<DiContainer>());
|
||||
|
||||
var _placeItems = <_PlaceItem>[];
|
||||
var _countryItems = <_CountryItem>[];
|
||||
|
||||
static final _log = Logger("widget.places_browser._PlacesBrowserState");
|
||||
}
|
||||
|
||||
class _PlaceItem {
|
||||
const _PlaceItem({
|
||||
required this.account,
|
||||
required this.place,
|
||||
required this.thumbUrl,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
Widget buildWidget(BuildContext context) => CollectionListSmall(
|
||||
account: account,
|
||||
label: place,
|
||||
coverUrl: thumbUrl,
|
||||
fallbackBuilder: (_) => Icon(
|
||||
Icons.location_on,
|
||||
color: Colors.white.withOpacity(.8),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
final Account account;
|
||||
final String place;
|
||||
final String thumbUrl;
|
||||
final VoidCallback? onTap;
|
||||
}
|
||||
|
||||
class _CountryItem {
|
||||
const _CountryItem({
|
||||
required this.account,
|
||||
required this.country,
|
||||
required this.thumbUrl,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
Widget buildWidget(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
cacheManager: ThumbnailCacheManager.inst,
|
||||
imageUrl: thumbUrl,
|
||||
httpHeaders: {
|
||||
"Authorization": Api.getAuthorizationHeaderValue(account),
|
||||
},
|
||||
fadeInDuration: const Duration(),
|
||||
filterQuality: FilterQuality.high,
|
||||
errorWidget: (_, __, ___) => Padding(
|
||||
padding: const EdgeInsetsDirectional.fromSTEB(8, 8, 0, 8),
|
||||
child: Icon(
|
||||
Icons.location_on,
|
||||
color: AppTheme.getUnfocusedIconColor(context),
|
||||
),
|
||||
),
|
||||
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(country),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: AppTheme.getListItemBackgroundColor(context),
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Account account;
|
||||
final String country;
|
||||
final String thumbUrl;
|
||||
final VoidCallback? onTap;
|
||||
}
|
|
@ -21,8 +21,11 @@ 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/use_case/list_location_group.dart';
|
||||
import 'package:nc_photos/widget/people_browser.dart';
|
||||
import 'package:nc_photos/widget/person_browser.dart';
|
||||
import 'package:nc_photos/widget/place_browser.dart';
|
||||
import 'package:nc_photos/widget/places_browser.dart';
|
||||
|
||||
class SearchLanding extends StatefulWidget {
|
||||
const SearchLanding({
|
||||
|
@ -85,6 +88,7 @@ class _SearchLandingState extends State<SearchLanding> {
|
|||
children: [
|
||||
if (AccountPref.of(widget.account).isEnableFaceRecognitionAppOr())
|
||||
..._buildPeopleSection(context, state),
|
||||
..._buildLocationSection(context, state),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
title: Text(L10n.global().categoriesLabel),
|
||||
|
@ -113,7 +117,7 @@ class _SearchLandingState extends State<SearchLanding> {
|
|||
BuildContext context, SearchLandingBlocState state) {
|
||||
final isNoResult = (state is SearchLandingBlocSuccess ||
|
||||
state is SearchLandingBlocFailure) &&
|
||||
state.persons.isEmpty;
|
||||
_personItems.isEmpty;
|
||||
return [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
|
@ -147,26 +151,61 @@ class _SearchLandingState extends State<SearchLanding> {
|
|||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _items.length,
|
||||
itemBuilder: (context, i) => _buildItem(context, i),
|
||||
itemCount: _personItems.length,
|
||||
itemBuilder: (context, i) => _personItems[i].buildWidget(context),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildItem(BuildContext context, int index) {
|
||||
final item = _items[index];
|
||||
return item.buildWidget(context);
|
||||
List<Widget> _buildLocationSection(
|
||||
BuildContext context, SearchLandingBlocState state) {
|
||||
final isNoResult = (state is SearchLandingBlocSuccess ||
|
||||
state is SearchLandingBlocFailure) &&
|
||||
_locationItems.isEmpty;
|
||||
return [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
title: Text(L10n.global().collectionPlacesLabel),
|
||||
trailing: isNoResult
|
||||
? null
|
||||
: TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushNamed(PlacesBrowser.routeName,
|
||||
arguments: PlacesBrowserArguments(widget.account));
|
||||
},
|
||||
child: Text(L10n.global().showAllButtonLabel),
|
||||
),
|
||||
),
|
||||
if (isNoResult)
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: Text(L10n.global().listNoResultsText),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 128,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _locationItems.length,
|
||||
itemBuilder: (context, i) => _locationItems[i].buildWidget(context),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _onStateChange(BuildContext context, SearchLandingBlocState state) {
|
||||
if (state is SearchLandingBlocInit) {
|
||||
_items = [];
|
||||
_personItems = [];
|
||||
_locationItems = [];
|
||||
} else if (state is SearchLandingBlocSuccess ||
|
||||
state is SearchLandingBlocLoading) {
|
||||
_transformItems(state.persons);
|
||||
_transformItems(state.persons, state.locations);
|
||||
} else if (state is SearchLandingBlocFailure) {
|
||||
_transformItems(state.persons);
|
||||
_transformItems(state.persons, state.locations);
|
||||
try {
|
||||
final e = state.exception as ApiException;
|
||||
if (e.response.statusCode == 404) {
|
||||
|
@ -189,13 +228,26 @@ class _SearchLandingState extends State<SearchLanding> {
|
|||
widget.onVideoPressed?.call();
|
||||
}
|
||||
|
||||
void _onItemTap(Person person) {
|
||||
void _onPersonItemTap(Person person) {
|
||||
Navigator.pushNamed(context, PersonBrowser.routeName,
|
||||
arguments: PersonBrowserArguments(widget.account, person));
|
||||
}
|
||||
|
||||
void _transformItems(List<Person> items) {
|
||||
_items = items
|
||||
void _onLocationItemTap(LocationGroup location) {
|
||||
Navigator.of(context).pushNamed(
|
||||
PlaceBrowser.routeName,
|
||||
arguments: PlaceBrowserArguments(
|
||||
widget.account, location.place, location.countryCode),
|
||||
);
|
||||
}
|
||||
|
||||
void _transformItems(List<Person> persons, LocationGroupResult locations) {
|
||||
_transformPersons(persons);
|
||||
_transformLocations(locations);
|
||||
}
|
||||
|
||||
void _transformPersons(List<Person> persons) {
|
||||
_personItems = persons
|
||||
.sorted((a, b) {
|
||||
final countCompare = b.count.compareTo(a.count);
|
||||
if (countCompare == 0) {
|
||||
|
@ -209,7 +261,31 @@ class _SearchLandingState extends State<SearchLanding> {
|
|||
name: e.name,
|
||||
faceUrl: api_util.getFacePreviewUrl(widget.account, e.thumbFaceId,
|
||||
size: k.faceThumbSize),
|
||||
onTap: () => _onItemTap(e),
|
||||
onTap: () => _onPersonItemTap(e),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
void _transformLocations(LocationGroupResult locations) {
|
||||
_locationItems = locations.name
|
||||
.sorted((a, b) {
|
||||
final latestCompare = b.latest.compareTo(a.latest);
|
||||
if (latestCompare == 0) {
|
||||
return a.place.compareTo(b.place);
|
||||
} else {
|
||||
return latestCompare;
|
||||
}
|
||||
})
|
||||
.map((e) => _LandingLocationItem(
|
||||
account: widget.account,
|
||||
name: e.place,
|
||||
thumbUrl: api_util.getFilePreviewUrlByFileId(
|
||||
widget.account,
|
||||
e.latestFileId,
|
||||
width: k.photoThumbSize,
|
||||
height: k.photoThumbSize,
|
||||
),
|
||||
onTap: () => _onLocationItemTap(e),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
@ -220,7 +296,8 @@ class _SearchLandingState extends State<SearchLanding> {
|
|||
|
||||
late final _bloc = SearchLandingBloc(KiwiContainer().resolve<DiContainer>());
|
||||
|
||||
var _items = <_LandingPersonItem>[];
|
||||
var _personItems = <_LandingPersonItem>[];
|
||||
var _locationItems = <_LandingLocationItem>[];
|
||||
|
||||
static final _log = Logger("widget.search_landing._SearchLandingState");
|
||||
}
|
||||
|
@ -250,6 +327,31 @@ class _LandingPersonItem {
|
|||
final VoidCallback? onTap;
|
||||
}
|
||||
|
||||
class _LandingLocationItem {
|
||||
_LandingLocationItem({
|
||||
required this.account,
|
||||
required this.name,
|
||||
required this.thumbUrl,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
Widget buildWidget(BuildContext context) => _LandingItemWidget(
|
||||
account: account,
|
||||
label: name,
|
||||
coverUrl: thumbUrl,
|
||||
onTap: onTap,
|
||||
fallbackBuilder: (_) => Icon(
|
||||
Icons.location_on,
|
||||
color: Colors.white.withOpacity(.8),
|
||||
),
|
||||
);
|
||||
|
||||
final Account account;
|
||||
final String name;
|
||||
final String thumbUrl;
|
||||
final VoidCallback? onTap;
|
||||
}
|
||||
|
||||
class _LandingItemWidget extends StatelessWidget {
|
||||
const _LandingItemWidget({
|
||||
Key? key,
|
||||
|
|
Loading…
Reference in a new issue