Show places in search page

This commit is contained in:
Ming Ming 2022-08-28 23:35:29 +08:00
parent e860f9767d
commit 88727f1818
12 changed files with 1573 additions and 36 deletions

View file

@ -56,6 +56,26 @@ String getFilePreviewUrlRelative(
return url; 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) { String getFileUrl(Account account, File file) {
return "${account.url}/${getFileUrlRelative(file)}"; return "${account.url}/${getFileUrlRelative(file)}";
} }

View 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");
}

View 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");
}

View file

@ -3,6 +3,7 @@ import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart'; import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/person.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'; import 'package:nc_photos/use_case/list_person.dart';
abstract class SearchLandingBlocEvent { abstract class SearchLandingBlocEvent {
@ -21,36 +22,41 @@ class SearchLandingBlocQuery extends SearchLandingBlocEvent {
} }
abstract class SearchLandingBlocState { abstract class SearchLandingBlocState {
const SearchLandingBlocState(this.account, this.persons); const SearchLandingBlocState(this.account, this.persons, this.locations);
@override @override
toString() => "$runtimeType {" toString() => "$runtimeType {"
"account: $account, " "account: $account, "
"persons: List {length: ${persons.length}}, " "persons: List {length: ${persons.length}}, "
"locations: $locations, "
"}"; "}";
final Account? account; final Account? account;
final List<Person> persons; final List<Person> persons;
final LocationGroupResult locations;
} }
class SearchLandingBlocInit extends SearchLandingBlocState { class SearchLandingBlocInit extends SearchLandingBlocState {
SearchLandingBlocInit() : super(null, const []); SearchLandingBlocInit()
: super(null, const [], const LocationGroupResult([], [], [], []));
} }
class SearchLandingBlocLoading extends SearchLandingBlocState { class SearchLandingBlocLoading extends SearchLandingBlocState {
const SearchLandingBlocLoading(Account? account, List<Person> persons) const SearchLandingBlocLoading(
: super(account, persons); Account? account, List<Person> persons, LocationGroupResult locations)
: super(account, persons, locations);
} }
class SearchLandingBlocSuccess extends SearchLandingBlocState { class SearchLandingBlocSuccess extends SearchLandingBlocState {
const SearchLandingBlocSuccess(Account? account, List<Person> persons) const SearchLandingBlocSuccess(
: super(account, persons); Account? account, List<Person> persons, LocationGroupResult locations)
: super(account, persons, locations);
} }
class SearchLandingBlocFailure extends SearchLandingBlocState { class SearchLandingBlocFailure extends SearchLandingBlocState {
const SearchLandingBlocFailure( const SearchLandingBlocFailure(Account? account, List<Person> persons,
Account? account, List<Person> persons, this.exception) LocationGroupResult locations, this.exception)
: super(account, persons); : super(account, persons, locations);
@override @override
toString() => "$runtimeType {" toString() => "$runtimeType {"
@ -83,17 +89,39 @@ class SearchLandingBloc
Future<void> _onEventQuery( Future<void> _onEventQuery(
SearchLandingBlocQuery ev, Emitter<SearchLandingBlocState> emit) async { SearchLandingBlocQuery ev, Emitter<SearchLandingBlocState> emit) async {
try { try {
emit(SearchLandingBlocLoading(ev.account, state.persons)); emit(
emit(SearchLandingBlocSuccess(ev.account, await _query(ev))); 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) { } catch (e, stackTrace) {
_log.severe("[_onEventQuery] Exception while request", 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); ListPerson(_c.withLocalRepo())(ev.account);
Future<LocationGroupResult> _queryLocations(SearchLandingBlocQuery ev) =>
ListLocationGroup(_c.withLocalRepo())(ev.account);
final DiContainer _c; final DiContainer _c;
static final _log = Logger("bloc.search_landing.SearchLandingBloc"); static final _log = Logger("bloc.search_landing.SearchLandingBloc");

View file

@ -1389,6 +1389,10 @@
"@gpsPlaceAboutDialogContent": { "@gpsPlaceAboutDialogContent": {
"description": "Warn about the inaccurate nature of our offline reverse geocoding feature (i.e., converting coordinates into addresses)" "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": "Unauthenticated access. Please sign-in again if the problem continues",
"@errorUnauthenticated": { "@errorUnauthenticated": {

View file

@ -146,6 +146,7 @@
"gpsPlaceText", "gpsPlaceText",
"gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent", "gpsPlaceAboutDialogContent",
"collectionPlacesLabel",
"errorAlbumDowngrade" "errorAlbumDowngrade"
], ],
@ -310,6 +311,7 @@
"gpsPlaceText", "gpsPlaceText",
"gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent", "gpsPlaceAboutDialogContent",
"collectionPlacesLabel",
"errorAlbumDowngrade" "errorAlbumDowngrade"
], ],
@ -354,7 +356,8 @@
"showAllButtonLabel", "showAllButtonLabel",
"gpsPlaceText", "gpsPlaceText",
"gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent" "gpsPlaceAboutDialogContent",
"collectionPlacesLabel"
], ],
"es": [ "es": [
@ -363,13 +366,15 @@
"showAllButtonLabel", "showAllButtonLabel",
"gpsPlaceText", "gpsPlaceText",
"gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent" "gpsPlaceAboutDialogContent",
"collectionPlacesLabel"
], ],
"fi": [ "fi": [
"gpsPlaceText", "gpsPlaceText",
"gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent" "gpsPlaceAboutDialogContent",
"collectionPlacesLabel"
], ],
"fr": [ "fr": [
@ -438,7 +443,8 @@
"showAllButtonLabel", "showAllButtonLabel",
"gpsPlaceText", "gpsPlaceText",
"gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent" "gpsPlaceAboutDialogContent",
"collectionPlacesLabel"
], ],
"pl": [ "pl": [
@ -524,7 +530,8 @@
"showAllButtonLabel", "showAllButtonLabel",
"gpsPlaceText", "gpsPlaceText",
"gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent" "gpsPlaceAboutDialogContent",
"collectionPlacesLabel"
], ],
"pt": [ "pt": [
@ -589,7 +596,8 @@
"showAllButtonLabel", "showAllButtonLabel",
"gpsPlaceText", "gpsPlaceText",
"gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent" "gpsPlaceAboutDialogContent",
"collectionPlacesLabel"
], ],
"ru": [ "ru": [
@ -654,7 +662,8 @@
"showAllButtonLabel", "showAllButtonLabel",
"gpsPlaceText", "gpsPlaceText",
"gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent" "gpsPlaceAboutDialogContent",
"collectionPlacesLabel"
], ],
"zh": [ "zh": [
@ -719,7 +728,8 @@
"showAllButtonLabel", "showAllButtonLabel",
"gpsPlaceText", "gpsPlaceText",
"gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent" "gpsPlaceAboutDialogContent",
"collectionPlacesLabel"
], ],
"zh_Hant": [ "zh_Hant": [
@ -784,6 +794,7 @@
"showAllButtonLabel", "showAllButtonLabel",
"gpsPlaceText", "gpsPlaceText",
"gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogTitle",
"gpsPlaceAboutDialogContent" "gpsPlaceAboutDialogContent",
"collectionPlacesLabel"
] ]
} }

View 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;
}

View 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");
}

View file

@ -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/local_file_viewer.dart';
import 'package:nc_photos/widget/people_browser.dart'; import 'package:nc_photos/widget/people_browser.dart';
import 'package:nc_photos/widget/person_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/root_picker.dart';
import 'package:nc_photos/widget/settings.dart'; import 'package:nc_photos/widget/settings.dart';
import 'package:nc_photos/widget/setup.dart'; import 'package:nc_photos/widget/setup.dart';
@ -167,6 +169,8 @@ class _MyAppState extends State<MyApp>
route ??= _handleChangelogRoute(settings); route ??= _handleChangelogRoute(settings);
route ??= _handleTagBrowserRoute(settings); route ??= _handleTagBrowserRoute(settings);
route ??= _handlePeopleBrowserRoute(settings); route ??= _handlePeopleBrowserRoute(settings);
route ??= _handlePlaceBrowserRoute(settings);
route ??= _handlePlacesBrowserRoute(settings);
return route; return route;
} }
@ -570,6 +574,32 @@ class _MyAppState extends State<MyApp>
return null; 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 _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
final _navigatorKey = GlobalKey<NavigatorState>(); final _navigatorKey = GlobalKey<NavigatorState>();

View 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,
}

View 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;
}

View file

@ -21,8 +21,11 @@ import 'package:nc_photos/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme.dart';
import 'package:nc_photos/url_launcher_util.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/people_browser.dart';
import 'package:nc_photos/widget/person_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 { class SearchLanding extends StatefulWidget {
const SearchLanding({ const SearchLanding({
@ -85,6 +88,7 @@ class _SearchLandingState extends State<SearchLanding> {
children: [ children: [
if (AccountPref.of(widget.account).isEnableFaceRecognitionAppOr()) if (AccountPref.of(widget.account).isEnableFaceRecognitionAppOr())
..._buildPeopleSection(context, state), ..._buildPeopleSection(context, state),
..._buildLocationSection(context, state),
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Text(L10n.global().categoriesLabel), title: Text(L10n.global().categoriesLabel),
@ -113,7 +117,7 @@ class _SearchLandingState extends State<SearchLanding> {
BuildContext context, SearchLandingBlocState state) { BuildContext context, SearchLandingBlocState state) {
final isNoResult = (state is SearchLandingBlocSuccess || final isNoResult = (state is SearchLandingBlocSuccess ||
state is SearchLandingBlocFailure) && state is SearchLandingBlocFailure) &&
state.persons.isEmpty; _personItems.isEmpty;
return [ return [
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 16),
@ -147,26 +151,61 @@ class _SearchLandingState extends State<SearchLanding> {
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: _items.length, itemCount: _personItems.length,
itemBuilder: (context, i) => _buildItem(context, i), itemBuilder: (context, i) => _personItems[i].buildWidget(context),
), ),
), ),
]; ];
} }
Widget _buildItem(BuildContext context, int index) { List<Widget> _buildLocationSection(
final item = _items[index]; BuildContext context, SearchLandingBlocState state) {
return item.buildWidget(context); 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) { void _onStateChange(BuildContext context, SearchLandingBlocState state) {
if (state is SearchLandingBlocInit) { if (state is SearchLandingBlocInit) {
_items = []; _personItems = [];
_locationItems = [];
} else if (state is SearchLandingBlocSuccess || } else if (state is SearchLandingBlocSuccess ||
state is SearchLandingBlocLoading) { state is SearchLandingBlocLoading) {
_transformItems(state.persons); _transformItems(state.persons, state.locations);
} else if (state is SearchLandingBlocFailure) { } else if (state is SearchLandingBlocFailure) {
_transformItems(state.persons); _transformItems(state.persons, state.locations);
try { try {
final e = state.exception as ApiException; final e = state.exception as ApiException;
if (e.response.statusCode == 404) { if (e.response.statusCode == 404) {
@ -189,13 +228,26 @@ class _SearchLandingState extends State<SearchLanding> {
widget.onVideoPressed?.call(); widget.onVideoPressed?.call();
} }
void _onItemTap(Person person) { void _onPersonItemTap(Person person) {
Navigator.pushNamed(context, PersonBrowser.routeName, Navigator.pushNamed(context, PersonBrowser.routeName,
arguments: PersonBrowserArguments(widget.account, person)); arguments: PersonBrowserArguments(widget.account, person));
} }
void _transformItems(List<Person> items) { void _onLocationItemTap(LocationGroup location) {
_items = items 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) { .sorted((a, b) {
final countCompare = b.count.compareTo(a.count); final countCompare = b.count.compareTo(a.count);
if (countCompare == 0) { if (countCompare == 0) {
@ -209,7 +261,31 @@ class _SearchLandingState extends State<SearchLanding> {
name: e.name, name: e.name,
faceUrl: api_util.getFacePreviewUrl(widget.account, e.thumbFaceId, faceUrl: api_util.getFacePreviewUrl(widget.account, e.thumbFaceId,
size: k.faceThumbSize), 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(); .toList();
} }
@ -220,7 +296,8 @@ class _SearchLandingState extends State<SearchLanding> {
late final _bloc = SearchLandingBloc(KiwiContainer().resolve<DiContainer>()); late final _bloc = SearchLandingBloc(KiwiContainer().resolve<DiContainer>());
var _items = <_LandingPersonItem>[]; var _personItems = <_LandingPersonItem>[];
var _locationItems = <_LandingLocationItem>[];
static final _log = Logger("widget.search_landing._SearchLandingState"); static final _log = Logger("widget.search_landing._SearchLandingState");
} }
@ -250,6 +327,31 @@ class _LandingPersonItem {
final VoidCallback? onTap; 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 { class _LandingItemWidget extends StatelessWidget {
const _LandingItemWidget({ const _LandingItemWidget({
Key? key, Key? key,