mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-22 08:46:18 +01:00
Merge branch 'map-browser'
This commit is contained in:
commit
5903b45e11
34 changed files with 1771 additions and 25 deletions
|
@ -16,6 +16,8 @@ import 'package:nc_photos/entity/file.dart';
|
|||
import 'package:nc_photos/entity/file/data_source.dart';
|
||||
import 'package:nc_photos/entity/file/data_source2.dart';
|
||||
import 'package:nc_photos/entity/file/repo.dart';
|
||||
import 'package:nc_photos/entity/image_location/data_source.dart';
|
||||
import 'package:nc_photos/entity/image_location/repo.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/entity/local_file/data_source.dart';
|
||||
import 'package:nc_photos/entity/nc_album/data_source.dart';
|
||||
|
@ -187,6 +189,8 @@ Future<void> _initDiContainer(InitIsolateType isolateType) async {
|
|||
const BasicRecognizeFaceRepo(RecognizeFaceRemoteDataSource());
|
||||
c.recognizeFaceRepoLocal =
|
||||
BasicRecognizeFaceRepo(RecognizeFaceSqliteDbDataSource(c.npDb));
|
||||
c.imageLocationRepo =
|
||||
BasicImageLocationRepo(ImageLocationNpDbDataSource(c.npDb));
|
||||
|
||||
c.touchManager = TouchManager(c);
|
||||
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
// ignore_for_file: deprecated_member_use_from_same_package
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/collection/util.dart';
|
||||
import 'package:nc_photos/entity/pref.dart';
|
||||
import 'package:nc_photos/json_util.dart';
|
||||
import 'package:nc_photos/language_util.dart';
|
||||
import 'package:nc_photos/object_extension.dart';
|
||||
import 'package:nc_photos/protected_page_handler.dart';
|
||||
|
@ -158,6 +161,13 @@ class PrefController {
|
|||
value: value,
|
||||
);
|
||||
|
||||
Future<bool> setMapBrowserPrevPosition(MapCoord value) => _set<MapCoord?>(
|
||||
controller: _mapBrowserPrevPositionController,
|
||||
setter: (pref, value) => pref.setMapBrowserPrevPosition(
|
||||
jsonEncode([value!.latitude, value.longitude])),
|
||||
value: value,
|
||||
);
|
||||
|
||||
Future<bool> _set<T>({
|
||||
required BehaviorSubject<T> controller,
|
||||
required Future<bool> Function(Pref pref, T value) setter,
|
||||
|
@ -258,6 +268,11 @@ class PrefController {
|
|||
@npSubjectAccessor
|
||||
late final _isDontShowVideoPreviewHintController =
|
||||
BehaviorSubject.seeded(_c.pref.isDontShowVideoPreviewHintOr(false));
|
||||
@npSubjectAccessor
|
||||
late final _mapBrowserPrevPositionController = BehaviorSubject.seeded(_c.pref
|
||||
.getMapBrowserPrevPosition()
|
||||
?.let(tryJsonDecode)
|
||||
?.let(_tryMapCoordFromJson));
|
||||
}
|
||||
|
||||
@npSubjectAccessor
|
||||
|
|
|
@ -157,6 +157,15 @@ extension $PrefControllerNpSubjectAccessor on PrefController {
|
|||
isDontShowVideoPreviewHint.distinct().skip(1);
|
||||
bool get isDontShowVideoPreviewHintValue =>
|
||||
_isDontShowVideoPreviewHintController.value;
|
||||
// _mapBrowserPrevPositionController
|
||||
ValueStream<MapCoord?> get mapBrowserPrevPosition =>
|
||||
_mapBrowserPrevPositionController.stream;
|
||||
Stream<MapCoord?> get mapBrowserPrevPositionNew =>
|
||||
mapBrowserPrevPosition.skip(1);
|
||||
Stream<MapCoord?> get mapBrowserPrevPositionChange =>
|
||||
mapBrowserPrevPosition.distinct().skip(1);
|
||||
MapCoord? get mapBrowserPrevPositionValue =>
|
||||
_mapBrowserPrevPositionController.value;
|
||||
}
|
||||
|
||||
extension $SecurePrefControllerNpSubjectAccessor on SecurePrefController {
|
||||
|
|
|
@ -88,4 +88,20 @@ extension on Pref {
|
|||
isDontShowVideoPreviewHint() ?? def;
|
||||
Future<bool> setDontShowVideoPreviewHint(bool value) =>
|
||||
provider.setBool(PrefKey.dontShowVideoPreviewHint, value);
|
||||
|
||||
String? getMapBrowserPrevPosition() =>
|
||||
provider.getString(PrefKey.mapBrowserPrevPosition);
|
||||
Future<bool> setMapBrowserPrevPosition(String value) =>
|
||||
provider.setString(PrefKey.mapBrowserPrevPosition, value);
|
||||
}
|
||||
|
||||
MapCoord? _tryMapCoordFromJson(dynamic json) {
|
||||
try {
|
||||
final j = (json as List).cast<double>();
|
||||
return MapCoord(j[0], j[1]);
|
||||
} catch (e, stackTrace) {
|
||||
_$__NpLog.log
|
||||
.severe("[_tryMapCoordFromJson] Failed to parse json", e, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import 'package:nc_photos/entity/exif.dart';
|
|||
import 'package:nc_photos/entity/face_recognition_person.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/entity/image_location/repo.dart';
|
||||
import 'package:nc_photos/entity/nc_album.dart';
|
||||
import 'package:nc_photos/entity/nc_album_item.dart';
|
||||
import 'package:nc_photos/entity/recognize_face.dart';
|
||||
|
@ -275,6 +276,16 @@ abstract class DbLocationGroupConverter {
|
|||
}
|
||||
}
|
||||
|
||||
abstract class DbImageLatLngConverter {
|
||||
static ImageLatLng fromDb(DbImageLatLng src) {
|
||||
return ImageLatLng(
|
||||
latitude: src.lat,
|
||||
longitude: src.lng,
|
||||
fileId: src.fileId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension FileExtension on File {
|
||||
DbFileKey toDbKey() {
|
||||
if (fileId != null) {
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:nc_photos/entity/face_recognition_person/repo.dart';
|
|||
import 'package:nc_photos/entity/favorite.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file/repo.dart';
|
||||
import 'package:nc_photos/entity/image_location/repo.dart';
|
||||
import 'package:nc_photos/entity/local_file.dart';
|
||||
import 'package:nc_photos/entity/nc_album/repo.dart';
|
||||
import 'package:nc_photos/entity/pref.dart';
|
||||
|
@ -48,6 +49,7 @@ enum DiType {
|
|||
recognizeFaceRepo,
|
||||
recognizeFaceRepoRemote,
|
||||
recognizeFaceRepoLocal,
|
||||
imageLocationRepo,
|
||||
pref,
|
||||
touchManager,
|
||||
npDb,
|
||||
|
@ -86,6 +88,7 @@ class DiContainer {
|
|||
RecognizeFaceRepo? recognizeFaceRepo,
|
||||
RecognizeFaceRepo? recognizeFaceRepoRemote,
|
||||
RecognizeFaceRepo? recognizeFaceRepoLocal,
|
||||
ImageLocationRepo? imageLocationRepo,
|
||||
Pref? pref,
|
||||
TouchManager? touchManager,
|
||||
NpDb? npDb,
|
||||
|
@ -120,6 +123,7 @@ class DiContainer {
|
|||
_recognizeFaceRepo = recognizeFaceRepo,
|
||||
_recognizeFaceRepoRemote = recognizeFaceRepoRemote,
|
||||
_recognizeFaceRepoLocal = recognizeFaceRepoLocal,
|
||||
_imageLocationRepo = imageLocationRepo,
|
||||
_pref = pref,
|
||||
_touchManager = touchManager,
|
||||
_npDb = npDb,
|
||||
|
@ -189,6 +193,8 @@ class DiContainer {
|
|||
return contianer._recognizeFaceRepoRemote != null;
|
||||
case DiType.recognizeFaceRepoLocal:
|
||||
return contianer._recognizeFaceRepoLocal != null;
|
||||
case DiType.imageLocationRepo:
|
||||
return contianer._imageLocationRepo != null;
|
||||
case DiType.pref:
|
||||
return contianer._pref != null;
|
||||
case DiType.touchManager:
|
||||
|
@ -215,6 +221,7 @@ class DiContainer {
|
|||
OrNull<NcAlbumRepo>? ncAlbumRepo,
|
||||
OrNull<FaceRecognitionPersonRepo>? faceRecognitionPersonRepo,
|
||||
OrNull<RecognizeFaceRepo>? recognizeFaceRepo,
|
||||
OrNull<ImageLocationRepo>? imageLocationRepo,
|
||||
OrNull<Pref>? pref,
|
||||
OrNull<TouchManager>? touchManager,
|
||||
OrNull<NpDb>? npDb,
|
||||
|
@ -240,6 +247,9 @@ class DiContainer {
|
|||
recognizeFaceRepo: recognizeFaceRepo == null
|
||||
? _recognizeFaceRepo
|
||||
: recognizeFaceRepo.obj,
|
||||
imageLocationRepo: imageLocationRepo == null
|
||||
? _imageLocationRepo
|
||||
: imageLocationRepo.obj,
|
||||
pref: pref == null ? _pref : pref.obj,
|
||||
touchManager: touchManager == null ? _touchManager : touchManager.obj,
|
||||
npDb: npDb == null ? _npDb : npDb.obj,
|
||||
|
@ -280,6 +290,7 @@ class DiContainer {
|
|||
RecognizeFaceRepo get recognizeFaceRepo => _recognizeFaceRepo!;
|
||||
RecognizeFaceRepo get recognizeFaceRepoRemote => _recognizeFaceRepoRemote!;
|
||||
RecognizeFaceRepo get recognizeFaceRepoLocal => _recognizeFaceRepoLocal!;
|
||||
ImageLocationRepo get imageLocationRepo => _imageLocationRepo!;
|
||||
|
||||
Pref get pref => _pref!;
|
||||
TouchManager get touchManager => _touchManager!;
|
||||
|
@ -436,6 +447,11 @@ class DiContainer {
|
|||
_recognizeFaceRepoLocal = v;
|
||||
}
|
||||
|
||||
set imageLocationRepo(ImageLocationRepo v) {
|
||||
assert(_imageLocationRepo == null);
|
||||
_imageLocationRepo = v;
|
||||
}
|
||||
|
||||
set pref(Pref v) {
|
||||
assert(_pref == null);
|
||||
_pref = v;
|
||||
|
@ -489,6 +505,7 @@ class DiContainer {
|
|||
RecognizeFaceRepo? _recognizeFaceRepo;
|
||||
RecognizeFaceRepo? _recognizeFaceRepoRemote;
|
||||
RecognizeFaceRepo? _recognizeFaceRepoLocal;
|
||||
ImageLocationRepo? _imageLocationRepo;
|
||||
|
||||
Pref? _pref;
|
||||
TouchManager? _touchManager;
|
||||
|
|
|
@ -2,12 +2,14 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/collection.dart';
|
||||
import 'package:nc_photos/entity/collection/adapter/ad_hoc.dart';
|
||||
import 'package:nc_photos/entity/collection/adapter/album.dart';
|
||||
import 'package:nc_photos/entity/collection/adapter/location_group.dart';
|
||||
import 'package:nc_photos/entity/collection/adapter/memory.dart';
|
||||
import 'package:nc_photos/entity/collection/adapter/nc_album.dart';
|
||||
import 'package:nc_photos/entity/collection/adapter/person.dart';
|
||||
import 'package:nc_photos/entity/collection/adapter/tag.dart';
|
||||
import 'package:nc_photos/entity/collection/content_provider/ad_hoc.dart';
|
||||
import 'package:nc_photos/entity/collection/content_provider/album.dart';
|
||||
import 'package:nc_photos/entity/collection/content_provider/location_group.dart';
|
||||
import 'package:nc_photos/entity/collection/content_provider/memory.dart';
|
||||
|
@ -30,6 +32,8 @@ abstract class CollectionAdapter {
|
|||
static CollectionAdapter of(
|
||||
DiContainer c, Account account, Collection collection) {
|
||||
switch (collection.contentProvider.runtimeType) {
|
||||
case const (CollectionAdHocProvider):
|
||||
return CollectionAdHocAdapter(c, account, collection);
|
||||
case const (CollectionAlbumProvider):
|
||||
return CollectionAlbumAdapter(c, account, collection);
|
||||
case const (CollectionLocationGroupProvider):
|
||||
|
|
57
app/lib/entity/collection/adapter/ad_hoc.dart
Normal file
57
app/lib/entity/collection/adapter/ad_hoc.dart
Normal file
|
@ -0,0 +1,57 @@
|
|||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/collection.dart';
|
||||
import 'package:nc_photos/entity/collection/adapter.dart';
|
||||
import 'package:nc_photos/entity/collection/adapter/adapter_mixin.dart';
|
||||
import 'package:nc_photos/entity/collection/content_provider/ad_hoc.dart';
|
||||
import 'package:nc_photos/entity/collection_item.dart';
|
||||
import 'package:nc_photos/entity/collection_item/basic_item.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/use_case/find_file_descriptor.dart';
|
||||
|
||||
class CollectionAdHocAdapter
|
||||
with
|
||||
CollectionAdapterReadOnlyTag,
|
||||
CollectionAdapterUnremovableTag,
|
||||
CollectionAdapterUnshareableTag
|
||||
implements CollectionAdapter {
|
||||
CollectionAdHocAdapter(this._c, this.account, this.collection)
|
||||
: _provider = collection.contentProvider as CollectionAdHocProvider;
|
||||
|
||||
@override
|
||||
Stream<List<CollectionItem>> listItem() async* {
|
||||
final files = await FindFileDescriptor(_c)(
|
||||
account,
|
||||
_provider.fileIds,
|
||||
onFileNotFound: (_) {
|
||||
// ignore not found
|
||||
},
|
||||
);
|
||||
yield files
|
||||
.where((f) => file_util.isSupportedFormat(f))
|
||||
.map((f) => BasicCollectionFileItem(f))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CollectionItem> adaptToNewItem(CollectionItem original) async {
|
||||
if (original is CollectionFileItem) {
|
||||
return BasicCollectionFileItem(original.file);
|
||||
} else {
|
||||
throw UnsupportedError("Unsupported type: ${original.runtimeType}");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool isItemDeletable(CollectionItem item) => true;
|
||||
|
||||
@override
|
||||
bool isPermitted(CollectionCapability capability) =>
|
||||
_provider.capabilities.contains(capability);
|
||||
|
||||
final DiContainer _c;
|
||||
final Account account;
|
||||
final Collection collection;
|
||||
|
||||
final CollectionAdHocProvider _provider;
|
||||
}
|
83
app/lib/entity/collection/content_provider/ad_hoc.dart
Normal file
83
app/lib/entity/collection/content_provider/ad_hoc.dart
Normal file
|
@ -0,0 +1,83 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||
import 'package:nc_photos/entity/collection.dart';
|
||||
import 'package:nc_photos/entity/collection/util.dart';
|
||||
import 'package:nc_photos/entity/collection_item/util.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:np_common/object_util.dart';
|
||||
import 'package:to_string/to_string.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
part 'ad_hoc.g.dart';
|
||||
|
||||
@toString
|
||||
class CollectionAdHocProvider
|
||||
with EquatableMixin
|
||||
implements CollectionContentProvider {
|
||||
CollectionAdHocProvider({
|
||||
required this.account,
|
||||
required this.fileIds,
|
||||
this.cover,
|
||||
});
|
||||
|
||||
@override
|
||||
String get fourCc => "ADHC";
|
||||
|
||||
@override
|
||||
String get id => _id;
|
||||
|
||||
@override
|
||||
int? get count => fileIds.length;
|
||||
|
||||
@override
|
||||
DateTime get lastModified => clock.now();
|
||||
|
||||
@override
|
||||
List<CollectionCapability> get capabilities => [
|
||||
CollectionCapability.deleteItem,
|
||||
];
|
||||
|
||||
@override
|
||||
CollectionItemSort get itemSort => CollectionItemSort.dateDescending;
|
||||
|
||||
@override
|
||||
List<CollectionShare> get shares => [];
|
||||
|
||||
@override
|
||||
String? getCoverUrl(
|
||||
int width,
|
||||
int height, {
|
||||
bool? isKeepAspectRatio,
|
||||
}) {
|
||||
return cover?.let((cover) => api_util.getFilePreviewUrl(
|
||||
account,
|
||||
cover,
|
||||
width: width,
|
||||
height: height,
|
||||
isKeepAspectRatio: isKeepAspectRatio ?? false,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isDynamicCollection => true;
|
||||
|
||||
@override
|
||||
bool get isPendingSharedAlbum => false;
|
||||
|
||||
@override
|
||||
bool get isOwned => true;
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [account, fileIds, cover];
|
||||
|
||||
final Account account;
|
||||
final List<int> fileIds;
|
||||
final FileDescriptor? cover;
|
||||
|
||||
late final _id = const Uuid().v4();
|
||||
}
|
14
app/lib/entity/collection/content_provider/ad_hoc.g.dart
Normal file
14
app/lib/entity/collection/content_provider/ad_hoc.g.dart
Normal file
|
@ -0,0 +1,14 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'ad_hoc.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// ToStringGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$CollectionAdHocProviderToString on CollectionAdHocProvider {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "CollectionAdHocProvider {account: $account, fileIds: [length: ${fileIds.length}], cover: ${cover == null ? null : "${cover!.fdPath}"}, _id: $_id}";
|
||||
}
|
||||
}
|
38
app/lib/entity/image_location/data_source.dart
Normal file
38
app/lib/entity/image_location/data_source.dart
Normal file
|
@ -0,0 +1,38 @@
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/db/entity_converter.dart';
|
||||
import 'package:nc_photos/entity/file.dart';
|
||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||
import 'package:nc_photos/entity/image_location/repo.dart';
|
||||
import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util;
|
||||
import 'package:np_async/np_async.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_datetime/np_datetime.dart';
|
||||
import 'package:np_db/np_db.dart';
|
||||
|
||||
part 'data_source.g.dart';
|
||||
|
||||
@npLog
|
||||
class ImageLocationNpDbDataSource implements ImageLocationDataSource {
|
||||
const ImageLocationNpDbDataSource(this.db);
|
||||
|
||||
@override
|
||||
Future<List<ImageLatLng>> getLocations(
|
||||
Account account, TimeRange timeRange) async {
|
||||
_log.info("[getLocations] timeRange: $timeRange");
|
||||
final results = await db.getImageLatLngWithFileIds(
|
||||
account: account.toDb(),
|
||||
timeRange: timeRange,
|
||||
includeRelativeRoots: account.roots
|
||||
.map((e) => File(path: file_util.unstripPath(account, e))
|
||||
.strippedPathWithEmpty)
|
||||
.toList(),
|
||||
excludeRelativeRoots: [remote_storage_util.remoteStorageDirRelativePath],
|
||||
mimes: file_util.supportedFormatMimes,
|
||||
);
|
||||
return results.computeAll(DbImageLatLngConverter.fromDb);
|
||||
}
|
||||
|
||||
final NpDb db;
|
||||
}
|
15
app/lib/entity/image_location/data_source.g.dart
Normal file
15
app/lib/entity/image_location/data_source.g.dart
Normal file
|
@ -0,0 +1,15 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'data_source.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$ImageLocationNpDbDataSourceNpLog on ImageLocationNpDbDataSource {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log =
|
||||
Logger("entity.image_location.data_source.ImageLocationNpDbDataSource");
|
||||
}
|
52
app/lib/entity/image_location/repo.dart
Normal file
52
app/lib/entity/image_location/repo.dart
Normal file
|
@ -0,0 +1,52 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_datetime/np_datetime.dart';
|
||||
|
||||
part 'repo.g.dart';
|
||||
|
||||
class ImageLatLng with EquatableMixin {
|
||||
const ImageLatLng({
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.fileId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
latitude,
|
||||
longitude,
|
||||
fileId,
|
||||
];
|
||||
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final int fileId;
|
||||
}
|
||||
|
||||
abstract class ImageLocationRepo {
|
||||
/// Query all locations with the corresponding file ids
|
||||
///
|
||||
/// Returned data are sorted by the file date time in descending order
|
||||
Future<List<ImageLatLng>> getLocations(Account account, TimeRange timeRange);
|
||||
}
|
||||
|
||||
@npLog
|
||||
class BasicImageLocationRepo implements ImageLocationRepo {
|
||||
const BasicImageLocationRepo(this.dataSrc);
|
||||
|
||||
@override
|
||||
Future<List<ImageLatLng>> getLocations(
|
||||
Account account, TimeRange timeRange) =>
|
||||
dataSrc.getLocations(account, timeRange);
|
||||
|
||||
final ImageLocationDataSource dataSrc;
|
||||
}
|
||||
|
||||
abstract class ImageLocationDataSource {
|
||||
/// Query all locations with the corresponding file ids
|
||||
///
|
||||
/// Returned data are sorted by the file date time in descending order
|
||||
Future<List<ImageLatLng>> getLocations(Account account, TimeRange timeRange);
|
||||
}
|
15
app/lib/entity/image_location/repo.g.dart
Normal file
15
app/lib/entity/image_location/repo.g.dart
Normal file
|
@ -0,0 +1,15 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'repo.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$BasicImageLocationRepoNpLog on BasicImageLocationRepo {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log =
|
||||
Logger("entity.image_location.repo.BasicImageLocationRepo");
|
||||
}
|
|
@ -113,6 +113,7 @@ enum PrefKey implements PrefKeyInterface {
|
|||
protectedPageAuthPin,
|
||||
protectedPageAuthPassword,
|
||||
dontShowVideoPreviewHint,
|
||||
mapBrowserPrevPosition,
|
||||
;
|
||||
|
||||
@override
|
||||
|
@ -199,6 +200,8 @@ enum PrefKey implements PrefKeyInterface {
|
|||
return "protectedPageAuthPassword";
|
||||
case PrefKey.dontShowVideoPreviewHint:
|
||||
return "dontShowVideoPreviewHint";
|
||||
case PrefKey.mapBrowserPrevPosition:
|
||||
return "mapBrowserPrevPosition";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:nc_photos/object_extension.dart';
|
||||
|
||||
/// Convert a boolean to an indexable type in json for DB
|
||||
|
@ -8,3 +10,14 @@ Object? boolToJson(bool? value) => value?.run((v) => v ? 1 : 0);
|
|||
|
||||
/// Convert a boolean from an indexable type in json for DB
|
||||
bool? boolFromJson(Object? value) => value?.run((v) => v != 0);
|
||||
|
||||
Object? tryJsonDecode(String source) {
|
||||
try {
|
||||
return jsonDecode(source);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Object? jsonDecodeOr(String source, dynamic def) =>
|
||||
tryJsonDecode(source) ?? def;
|
||||
|
|
|
@ -1487,6 +1487,15 @@
|
|||
"trustedCertManagerFailedToRemoveCertError": "Failed to remove certificate",
|
||||
"missingVideoThumbnailHelpDialogTitle": "Having trouble with video thumbnails?",
|
||||
"dontShowAgain": "Don't show again",
|
||||
"mapBrowserDateRangeLabel": "Date range",
|
||||
"@mapBrowserDateRangeLabel": {
|
||||
"description": "Filter photos by date range"
|
||||
},
|
||||
"mapBrowserDateRangeThisMonth": "This month",
|
||||
"mapBrowserDateRangePrevMonth": "Previous month",
|
||||
"mapBrowserDateRangeThisYear": "This year",
|
||||
"mapBrowserDateRangeCustom": "Custom",
|
||||
"homeTabMapBrowser": "Map",
|
||||
|
||||
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
|
||||
"@errorUnauthenticated": {
|
||||
|
|
|
@ -249,6 +249,12 @@
|
|||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser",
|
||||
"errorUnauthenticated",
|
||||
"errorDisconnected",
|
||||
"errorLocked",
|
||||
|
@ -284,7 +290,13 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"de": [
|
||||
|
@ -318,7 +330,13 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"el": [
|
||||
|
@ -455,7 +473,13 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"es": [
|
||||
|
@ -483,7 +507,13 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"fi": [
|
||||
|
@ -511,7 +541,13 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"fr": [
|
||||
|
@ -539,7 +575,13 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"it": [
|
||||
|
@ -572,7 +614,13 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"nl": [
|
||||
|
@ -942,6 +990,12 @@
|
|||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser",
|
||||
"errorUnauthenticated",
|
||||
"errorDisconnected",
|
||||
"errorLocked",
|
||||
|
@ -981,7 +1035,13 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"pt": [
|
||||
|
@ -1029,7 +1089,13 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"ru": [
|
||||
|
@ -1057,7 +1123,22 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"tr": [
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"zh": [
|
||||
|
@ -1116,7 +1197,13 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
],
|
||||
|
||||
"zh_Hant": [
|
||||
|
@ -1269,6 +1356,12 @@
|
|||
"trustedCertManagerNoHttpsServerError",
|
||||
"trustedCertManagerFailedToRemoveCertError",
|
||||
"missingVideoThumbnailHelpDialogTitle",
|
||||
"dontShowAgain"
|
||||
"dontShowAgain",
|
||||
"mapBrowserDateRangeLabel",
|
||||
"mapBrowserDateRangeThisMonth",
|
||||
"mapBrowserDateRangePrevMonth",
|
||||
"mapBrowserDateRangeThisYear",
|
||||
"mapBrowserDateRangeCustom",
|
||||
"homeTabMapBrowser"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import 'package:nc_photos/use_case/import_potential_shared_album.dart';
|
|||
import 'package:nc_photos/widget/home_collections.dart';
|
||||
import 'package:nc_photos/widget/home_photos2.dart';
|
||||
import 'package:nc_photos/widget/home_search.dart';
|
||||
import 'package:nc_photos/widget/map_browser.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/or_null.dart';
|
||||
|
||||
|
@ -87,7 +88,7 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
|
|||
}
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
bottomNavigationBar: _buildBottomNavigationBar(context),
|
||||
body: Builder(builder: (context) => _buildContent(context)),
|
||||
|
@ -114,6 +115,11 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
|
|||
selectedIcon: const Icon(Icons.grid_view_sharp),
|
||||
label: L10n.global().collectionsTooltip,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
selectedIcon: const Icon(Icons.map),
|
||||
label: L10n.global().homeTabMapBrowser,
|
||||
),
|
||||
],
|
||||
selectedIndex: _nextPage,
|
||||
onDestinationSelected: _onTapNavItem,
|
||||
|
@ -125,7 +131,7 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
|
|||
return PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: 3,
|
||||
itemCount: 4,
|
||||
itemBuilder: (context, index) => SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, .05),
|
||||
|
@ -152,6 +158,9 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
|
|||
case 2:
|
||||
return const HomeCollections();
|
||||
|
||||
case 3:
|
||||
return const MapBrowser();
|
||||
|
||||
default:
|
||||
throw ArgumentError("Invalid page index: $index");
|
||||
}
|
||||
|
|
131
app/lib/widget/map_browser.dart
Normal file
131
app/lib/widget/map_browser.dart
Normal file
|
@ -0,0 +1,131 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:copy_with/copy_with.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:google_maps_cluster_manager/google_maps_cluster_manager.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
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_util.dart';
|
||||
import 'package:nc_photos/controller/account_controller.dart';
|
||||
import 'package:nc_photos/controller/pref_controller.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/collection.dart';
|
||||
import 'package:nc_photos/entity/collection/content_provider/ad_hoc.dart';
|
||||
import 'package:nc_photos/entity/image_location/repo.dart';
|
||||
import 'package:nc_photos/exception_event.dart';
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/snack_bar_manager.dart';
|
||||
import 'package:nc_photos/stream_extension.dart';
|
||||
import 'package:nc_photos/theme.dart';
|
||||
import 'package:nc_photos/theme/dimension.dart';
|
||||
import 'package:nc_photos/widget/collection_browser.dart';
|
||||
import 'package:nc_photos/widget/measure.dart';
|
||||
import 'package:nc_photos/widget/navigation_bar_blur_filter.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/object_util.dart';
|
||||
import 'package:np_datetime/np_datetime.dart';
|
||||
import 'package:np_gps_map/np_gps_map.dart';
|
||||
import 'package:to_string/to_string.dart';
|
||||
|
||||
part 'map_browser.g.dart';
|
||||
part 'map_browser/bloc.dart';
|
||||
part 'map_browser/state_event.dart';
|
||||
part 'map_browser/type.dart';
|
||||
part 'map_browser/view.dart';
|
||||
|
||||
class MapBrowser extends StatelessWidget {
|
||||
const MapBrowser({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => _Bloc(
|
||||
KiwiContainer().resolve(),
|
||||
account: context.read<AccountController>().account,
|
||||
prefController: context.read(),
|
||||
)..add(const _LoadData()),
|
||||
child: const _WrappedMapBrowser(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WrappedMapBrowser extends StatelessWidget {
|
||||
const _WrappedMapBrowser();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
_BlocListenerT<ExceptionEvent?>(
|
||||
selector: (state) => state.error,
|
||||
listener: (context, error) {
|
||||
if (error != null) {
|
||||
SnackBarManager().showSnackBarForException(error.error);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: Stack(
|
||||
children: [
|
||||
const _MapView(),
|
||||
Positioned.directional(
|
||||
textDirection: Directionality.of(context),
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
end: 8,
|
||||
child: const _DateRangeToggle(),
|
||||
),
|
||||
_BlocSelector<bool>(
|
||||
selector: (state) => state.isShowDataRangeControlPanel,
|
||||
builder: (context, isShowAnyPanel) => Positioned.fill(
|
||||
child: isShowAnyPanel
|
||||
? GestureDetector(
|
||||
onTap: () {
|
||||
context.addEvent(const _CloseControlPanel());
|
||||
},
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
child: _BlocSelector<bool>(
|
||||
selector: (state) => state.isShowDataRangeControlPanel,
|
||||
builder: (context, isShowDataRangeControlPanel) =>
|
||||
_PanelContainer(
|
||||
isShow: isShowDataRangeControlPanel,
|
||||
child: const _DateRangeControlPanel(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: NavigationBarBlurFilter(
|
||||
height: AppDimension.of(context).homeBottomAppBarHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
|
||||
// typedef _BlocListener = BlocListener<_Bloc, _State>;
|
||||
typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>;
|
||||
typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
|
||||
|
||||
extension on BuildContext {
|
||||
_Bloc get bloc => read<_Bloc>();
|
||||
// _State get state => bloc.state;
|
||||
void addEvent(_Event event) => bloc.add(event);
|
||||
}
|
128
app/lib/widget/map_browser.g.dart
Normal file
128
app/lib/widget/map_browser.g.dart
Normal file
|
@ -0,0 +1,128 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'map_browser.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithLintRuleGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class $_StateCopyWithWorker {
|
||||
_State call(
|
||||
{List<_DataPoint>? data,
|
||||
MapCoord? initialPoint,
|
||||
Set<Marker>? markers,
|
||||
bool? isShowDataRangeControlPanel,
|
||||
_DateRangeType? dateRangeType,
|
||||
DateRange? localDateRange,
|
||||
ExceptionEvent? error});
|
||||
}
|
||||
|
||||
class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
|
||||
_$_StateCopyWithWorkerImpl(this.that);
|
||||
|
||||
@override
|
||||
_State call(
|
||||
{dynamic data,
|
||||
dynamic initialPoint = copyWithNull,
|
||||
dynamic markers,
|
||||
dynamic isShowDataRangeControlPanel,
|
||||
dynamic dateRangeType,
|
||||
dynamic localDateRange,
|
||||
dynamic error = copyWithNull}) {
|
||||
return _State(
|
||||
data: data as List<_DataPoint>? ?? that.data,
|
||||
initialPoint: initialPoint == copyWithNull
|
||||
? that.initialPoint
|
||||
: initialPoint as MapCoord?,
|
||||
markers: markers as Set<Marker>? ?? that.markers,
|
||||
isShowDataRangeControlPanel: isShowDataRangeControlPanel as bool? ??
|
||||
that.isShowDataRangeControlPanel,
|
||||
dateRangeType: dateRangeType as _DateRangeType? ?? that.dateRangeType,
|
||||
localDateRange: localDateRange as DateRange? ?? that.localDateRange,
|
||||
error: error == copyWithNull ? that.error : error as ExceptionEvent?);
|
||||
}
|
||||
|
||||
final _State that;
|
||||
}
|
||||
|
||||
extension $_StateCopyWith on _State {
|
||||
$_StateCopyWithWorker get copyWith => _$copyWith;
|
||||
$_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this);
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$_BlocNpLog on _Bloc {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("widget.map_browser._Bloc");
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// ToStringGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$_StateToString on _State {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_State {data: [length: ${data.length}], initialPoint: $initialPoint, markers: {length: ${markers.length}}, isShowDataRangeControlPanel: $isShowDataRangeControlPanel, dateRangeType: ${dateRangeType.name}, localDateRange: $localDateRange, error: $error}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_LoadDataToString on _LoadData {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_LoadData {}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_SetMarkersToString on _SetMarkers {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_SetMarkers {markers: {length: ${markers.length}}}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_OpenDataRangeControlPanelToString on _OpenDataRangeControlPanel {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_OpenDataRangeControlPanel {}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_CloseControlPanelToString on _CloseControlPanel {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_CloseControlPanel {}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_SetDateRangeTypeToString on _SetDateRangeType {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_SetDateRangeType {value: ${value.name}}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_SetLocalDateRangeToString on _SetLocalDateRange {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_SetLocalDateRange {value: $value}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$_SetErrorToString on _SetError {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "_SetError {error: $error, stackTrace: $stackTrace}";
|
||||
}
|
||||
}
|
168
app/lib/widget/map_browser/bloc.dart
Normal file
168
app/lib/widget/map_browser/bloc.dart
Normal file
|
@ -0,0 +1,168 @@
|
|||
part of '../map_browser.dart';
|
||||
|
||||
@npLog
|
||||
class _Bloc extends Bloc<_Event, _State>
|
||||
with BlocLogger, BlocForEachMixin<_Event, _State> {
|
||||
_Bloc(
|
||||
this._c, {
|
||||
required this.account,
|
||||
required this.prefController,
|
||||
}) : super(_State.init(
|
||||
dateRangeType: _DateRangeType.thisMonth,
|
||||
localDateRange:
|
||||
_calcDateRange(clock.now().toDate(), _DateRangeType.thisMonth),
|
||||
)) {
|
||||
on<_LoadData>(_onLoadData);
|
||||
on<_SetMarkers>(_onSetMarkers);
|
||||
on<_OpenDataRangeControlPanel>(_onOpenDataRangeControlPanel);
|
||||
on<_CloseControlPanel>(_onCloseControlPanel);
|
||||
on<_SetDateRangeType>(_onSetDateRangeType);
|
||||
on<_SetLocalDateRange>(_onSetDateRange);
|
||||
on<_SetError>(_onSetError);
|
||||
|
||||
_subscriptions
|
||||
.add(stream.distinctBy((state) => state.localDateRange).listen((state) {
|
||||
add(const _LoadData());
|
||||
}));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
for (final s in _subscriptions) {
|
||||
s.cancel();
|
||||
}
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@override
|
||||
String get tag => _log.fullName;
|
||||
|
||||
@override
|
||||
void onError(Object error, StackTrace stackTrace) {
|
||||
// we need this to prevent onError being triggered recursively
|
||||
if (!isClosed && !_isHandlingError) {
|
||||
_isHandlingError = true;
|
||||
try {
|
||||
add(_SetError(error, stackTrace));
|
||||
} catch (_) {}
|
||||
_isHandlingError = false;
|
||||
}
|
||||
super.onError(error, stackTrace);
|
||||
}
|
||||
|
||||
Future<void> _onLoadData(_LoadData ev, Emitter<_State> emit) async {
|
||||
_log.info(ev);
|
||||
// convert local DateRange to TimeRange in UTC
|
||||
final localTimeRange = state.localDateRange.toLocalTimeRange();
|
||||
final utcTimeRange = localTimeRange.copyWith(
|
||||
from: localTimeRange.from?.toUtc(),
|
||||
to: localTimeRange.to?.toUtc(),
|
||||
);
|
||||
final raw = await _c.imageLocationRepo.getLocations(account, utcTimeRange);
|
||||
_log.info("[_onLoadData] Loaded ${raw.length} markers");
|
||||
if (state.initialPoint == null) {
|
||||
final initialPoint =
|
||||
raw.firstOrNull?.let((obj) => MapCoord(obj.latitude, obj.longitude));
|
||||
if (initialPoint != null) {
|
||||
unawaited(prefController.setMapBrowserPrevPosition(initialPoint));
|
||||
}
|
||||
emit(state.copyWith(
|
||||
data: raw.map(_DataPoint.fromImageLatLng).toList(),
|
||||
initialPoint: initialPoint,
|
||||
));
|
||||
} else {
|
||||
emit(state.copyWith(
|
||||
data: raw.map(_DataPoint.fromImageLatLng).toList(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
void _onSetMarkers(_SetMarkers ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
emit(state.copyWith(markers: ev.markers));
|
||||
}
|
||||
|
||||
void _onOpenDataRangeControlPanel(
|
||||
_OpenDataRangeControlPanel ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
emit(state.copyWith(
|
||||
isShowDataRangeControlPanel: true,
|
||||
));
|
||||
}
|
||||
|
||||
void _onCloseControlPanel(_CloseControlPanel ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
emit(state.copyWith(
|
||||
isShowDataRangeControlPanel: false,
|
||||
));
|
||||
}
|
||||
|
||||
void _onSetDateRangeType(_SetDateRangeType ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
emit(state.copyWith(
|
||||
dateRangeType: ev.value,
|
||||
localDateRange: ev.value == _DateRangeType.custom
|
||||
? null
|
||||
: _calcDateRange(clock.now().toDate(), ev.value),
|
||||
));
|
||||
}
|
||||
|
||||
void _onSetDateRange(_SetLocalDateRange ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
emit(state.copyWith(
|
||||
dateRangeType: _DateRangeType.custom,
|
||||
localDateRange: ev.value,
|
||||
));
|
||||
}
|
||||
|
||||
void _onSetError(_SetError ev, Emitter<_State> emit) {
|
||||
_log.info(ev);
|
||||
emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace)));
|
||||
}
|
||||
|
||||
static DateRange _calcDateRange(Date today, _DateRangeType type) {
|
||||
assert(type != _DateRangeType.custom);
|
||||
switch (type) {
|
||||
case _DateRangeType.thisMonth:
|
||||
return DateRange(
|
||||
from: today.copyWith(day: 1),
|
||||
to: today,
|
||||
toBound: TimeRangeBound.inclusive,
|
||||
);
|
||||
case _DateRangeType.prevMonth:
|
||||
if (today.month == 1) {
|
||||
return DateRange(
|
||||
from: Date(today.year - 1, 12, 1),
|
||||
to: Date(today.year - 1, 12, 31),
|
||||
toBound: TimeRangeBound.inclusive,
|
||||
);
|
||||
} else {
|
||||
return DateRange(
|
||||
from: Date(today.year, today.month - 1, 1),
|
||||
to: Date(today.year, today.month, 1).add(day: -1),
|
||||
toBound: TimeRangeBound.inclusive,
|
||||
);
|
||||
}
|
||||
case _DateRangeType.thisYear:
|
||||
return DateRange(
|
||||
from: today.copyWith(month: 1, day: 1),
|
||||
to: today,
|
||||
toBound: TimeRangeBound.inclusive,
|
||||
);
|
||||
case _DateRangeType.custom:
|
||||
return DateRange(
|
||||
from: today,
|
||||
to: today,
|
||||
toBound: TimeRangeBound.inclusive,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final DiContainer _c;
|
||||
final Account account;
|
||||
final PrefController prefController;
|
||||
|
||||
final _subscriptions = <StreamSubscription>[];
|
||||
|
||||
var _isHandlingError = false;
|
||||
}
|
110
app/lib/widget/map_browser/state_event.dart
Normal file
110
app/lib/widget/map_browser/state_event.dart
Normal file
|
@ -0,0 +1,110 @@
|
|||
part of '../map_browser.dart';
|
||||
|
||||
@genCopyWith
|
||||
@toString
|
||||
class _State {
|
||||
const _State({
|
||||
required this.data,
|
||||
this.initialPoint,
|
||||
required this.markers,
|
||||
required this.isShowDataRangeControlPanel,
|
||||
required this.dateRangeType,
|
||||
required this.localDateRange,
|
||||
this.error,
|
||||
});
|
||||
|
||||
factory _State.init({
|
||||
required _DateRangeType dateRangeType,
|
||||
required DateRange localDateRange,
|
||||
}) {
|
||||
return _State(
|
||||
data: const [],
|
||||
markers: const {},
|
||||
isShowDataRangeControlPanel: false,
|
||||
dateRangeType: dateRangeType,
|
||||
localDateRange: localDateRange,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final List<_DataPoint> data;
|
||||
final MapCoord? initialPoint;
|
||||
final Set<Marker> markers;
|
||||
|
||||
final bool isShowDataRangeControlPanel;
|
||||
final _DateRangeType dateRangeType;
|
||||
final DateRange localDateRange;
|
||||
|
||||
final ExceptionEvent? error;
|
||||
}
|
||||
|
||||
abstract class _Event {
|
||||
const _Event();
|
||||
}
|
||||
|
||||
@toString
|
||||
class _LoadData implements _Event {
|
||||
const _LoadData();
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
}
|
||||
|
||||
@toString
|
||||
class _SetMarkers implements _Event {
|
||||
const _SetMarkers(this.markers);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final Set<Marker> markers;
|
||||
}
|
||||
|
||||
@toString
|
||||
class _OpenDataRangeControlPanel implements _Event {
|
||||
const _OpenDataRangeControlPanel();
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
}
|
||||
|
||||
@toString
|
||||
class _CloseControlPanel implements _Event {
|
||||
const _CloseControlPanel();
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
}
|
||||
|
||||
@toString
|
||||
class _SetDateRangeType implements _Event {
|
||||
const _SetDateRangeType(this.value);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final _DateRangeType value;
|
||||
}
|
||||
|
||||
@toString
|
||||
class _SetLocalDateRange implements _Event {
|
||||
const _SetLocalDateRange(this.value);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final DateRange value;
|
||||
}
|
||||
|
||||
@toString
|
||||
class _SetError implements _Event {
|
||||
const _SetError(this.error, [this.stackTrace]);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final Object error;
|
||||
final StackTrace? stackTrace;
|
||||
}
|
46
app/lib/widget/map_browser/type.dart
Normal file
46
app/lib/widget/map_browser/type.dart
Normal file
|
@ -0,0 +1,46 @@
|
|||
part of '../map_browser.dart';
|
||||
|
||||
class _DataPoint implements ClusterItem {
|
||||
const _DataPoint({
|
||||
required this.location,
|
||||
required this.fileId,
|
||||
});
|
||||
|
||||
factory _DataPoint.fromImageLatLng(ImageLatLng src) => _DataPoint(
|
||||
location: LatLng(src.latitude, src.longitude),
|
||||
fileId: src.fileId,
|
||||
);
|
||||
|
||||
@override
|
||||
String get geohash =>
|
||||
Geohash.encode(location, codeLength: ClusterManager.precision);
|
||||
|
||||
@override
|
||||
final LatLng location;
|
||||
final int fileId;
|
||||
}
|
||||
|
||||
enum _DateRangeType {
|
||||
thisMonth,
|
||||
prevMonth,
|
||||
thisYear,
|
||||
custom,
|
||||
;
|
||||
|
||||
String toDisplayString() {
|
||||
switch (this) {
|
||||
case thisMonth:
|
||||
return L10n.global().mapBrowserDateRangeThisMonth;
|
||||
case prevMonth:
|
||||
return L10n.global().mapBrowserDateRangePrevMonth;
|
||||
case thisYear:
|
||||
return L10n.global().mapBrowserDateRangeThisYear;
|
||||
case custom:
|
||||
return L10n.global().mapBrowserDateRangeCustom;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on MapCoord {
|
||||
LatLng toLatLng() => LatLng(latitude, longitude);
|
||||
}
|
464
app/lib/widget/map_browser/view.dart
Normal file
464
app/lib/widget/map_browser/view.dart
Normal file
|
@ -0,0 +1,464 @@
|
|||
part of '../map_browser.dart';
|
||||
|
||||
class _MapView extends StatefulWidget {
|
||||
const _MapView();
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _MapViewState();
|
||||
}
|
||||
|
||||
class _MapViewState extends State<_MapView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
_BlocListenerT<List<_DataPoint>>(
|
||||
selector: (state) => state.data,
|
||||
listener: (context, data) {
|
||||
_clusterManager.setItems(data);
|
||||
},
|
||||
),
|
||||
_BlocListenerT<MapCoord?>(
|
||||
selector: (state) => state.initialPoint,
|
||||
listener: (context, initialPoint) {
|
||||
if (initialPoint != null) {
|
||||
_mapController?.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(initialPoint.toLatLng(), 10));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: _BlocBuilder(
|
||||
buildWhen: (previous, current) => previous.markers != current.markers,
|
||||
builder: (context, state) => GoogleMap(
|
||||
mapType: MapType.normal,
|
||||
initialCameraPosition: context
|
||||
.read<PrefController>()
|
||||
.mapBrowserPrevPositionValue
|
||||
?.let(
|
||||
(p) => CameraPosition(target: p.toLatLng(), zoom: 10)) ??
|
||||
const CameraPosition(target: LatLng(0, 0)),
|
||||
markers: state.markers,
|
||||
onMapCreated: (controller) {
|
||||
_clusterManager.setMapId(controller.mapId);
|
||||
_mapController = controller;
|
||||
if (Theme.of(context).brightness == Brightness.dark) {
|
||||
controller.setMapStyle(_mapStyleNight);
|
||||
}
|
||||
if (state.initialPoint != null) {
|
||||
controller.animateCamera(CameraUpdate.newLatLngZoom(
|
||||
state.initialPoint!.toLatLng(), 10));
|
||||
}
|
||||
},
|
||||
onCameraMove: _clusterManager.onCameraMove,
|
||||
onCameraIdle: _clusterManager.updateMap,
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<BitmapDescriptor> _getClusterBitmap(
|
||||
int size, {
|
||||
String? text,
|
||||
required Color color,
|
||||
}) async {
|
||||
final PictureRecorder pictureRecorder = PictureRecorder();
|
||||
final Canvas canvas = Canvas(pictureRecorder);
|
||||
final fillPaint = Paint()..color = color;
|
||||
final outlinePaint = Paint()
|
||||
..color = Theme.of(context).brightness == Brightness.light
|
||||
? Colors.black.withOpacity(.28)
|
||||
: Colors.white.withOpacity(.6)
|
||||
..strokeWidth = size / 28
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
const shadowPadding = 6.0;
|
||||
const shadowPaddingHalf = shadowPadding / 2;
|
||||
final shadowPath = Path()
|
||||
..addOval(
|
||||
Rect.fromLTWH(0, 0, size - shadowPadding, size - shadowPadding));
|
||||
canvas.drawShadow(shadowPath, Colors.black, 1, false);
|
||||
canvas.drawCircle(
|
||||
Offset(size / 2 - shadowPaddingHalf, size / 2 - shadowPaddingHalf),
|
||||
size / 2 - shadowPaddingHalf,
|
||||
fillPaint,
|
||||
);
|
||||
canvas.drawCircle(
|
||||
Offset(size / 2 - shadowPaddingHalf, size / 2 - shadowPaddingHalf),
|
||||
size / 2 - shadowPaddingHalf - (size / 28 / 2),
|
||||
outlinePaint,
|
||||
);
|
||||
|
||||
if (text != null) {
|
||||
TextPainter painter = TextPainter(textDirection: TextDirection.ltr);
|
||||
painter.text = TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
fontSize: size / 3 - ((text.length / 6) * (size * 0.1)),
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
);
|
||||
painter.layout();
|
||||
painter.paint(
|
||||
canvas,
|
||||
Offset(
|
||||
size / 2 - painter.width / 2 - shadowPaddingHalf,
|
||||
size / 2 - painter.height / 2 - shadowPaddingHalf,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final img = await pictureRecorder.endRecording().toImage(size, size);
|
||||
final data = await img.toByteData(format: ImageByteFormat.png) as ByteData;
|
||||
|
||||
return BitmapDescriptor.fromBytes(data.buffer.asUint8List());
|
||||
}
|
||||
|
||||
String _getMarkerCountString(int count) {
|
||||
switch (count) {
|
||||
case >= 10000:
|
||||
return "10000+";
|
||||
case >= 1000:
|
||||
return "${count ~/ 1000 * 1000}+";
|
||||
case >= 100:
|
||||
return "${count ~/ 100 * 100}+";
|
||||
case >= 10:
|
||||
return "${count ~/ 10 * 10}+";
|
||||
default:
|
||||
return count.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Color _getMarkerColor(int count) {
|
||||
const step = 1 / 4;
|
||||
final double r;
|
||||
switch (count) {
|
||||
case >= 10000:
|
||||
r = 1;
|
||||
case >= 1000:
|
||||
r = (count ~/ 1000) / 10 * step + step * 3;
|
||||
case >= 100:
|
||||
r = (count ~/ 100) / 10 * step + step * 2;
|
||||
case >= 10:
|
||||
r = (count ~/ 10) / 10 * step + step;
|
||||
default:
|
||||
r = (count / 10) * step;
|
||||
}
|
||||
if (Theme.of(context).brightness == Brightness.light) {
|
||||
return HSLColor.fromAHSL(
|
||||
1,
|
||||
_colorHsl.hue,
|
||||
r * .7 + .3,
|
||||
(_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1),
|
||||
).toColor();
|
||||
} else {
|
||||
return HSLColor.fromAHSL(
|
||||
1,
|
||||
_colorHsl.hue,
|
||||
r * .6 + .4,
|
||||
(_colorHsl.lightness - (.1 - r * .1)).clamp(0, 1),
|
||||
).toColor();
|
||||
}
|
||||
}
|
||||
|
||||
int _getMarkerSize(int count) {
|
||||
const step = 1 / 4;
|
||||
final double r;
|
||||
switch (count) {
|
||||
case >= 10000:
|
||||
r = 1;
|
||||
case >= 1000:
|
||||
r = (count ~/ 1000) / 10 * step + step * 3;
|
||||
case >= 100:
|
||||
r = (count ~/ 100) / 10 * step + step * 2;
|
||||
case >= 10:
|
||||
r = (count ~/ 10) / 10 * step + step;
|
||||
default:
|
||||
r = (count / 10) * step;
|
||||
}
|
||||
return (r * 85).toInt() + 85;
|
||||
}
|
||||
|
||||
late final _clusterManager = ClusterManager<_DataPoint>(
|
||||
const [],
|
||||
(markers) {
|
||||
if (mounted) {
|
||||
context.addEvent(_SetMarkers(markers));
|
||||
}
|
||||
},
|
||||
markerBuilder: (cluster) async => Marker(
|
||||
markerId: MarkerId(cluster.getId()),
|
||||
position: cluster.location,
|
||||
onTap: () {
|
||||
final c = Collection(
|
||||
name: "",
|
||||
contentProvider: CollectionAdHocProvider(
|
||||
account: context.bloc.account,
|
||||
fileIds: cluster.items.map((e) => e.fileId).toList(),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pushNamed(
|
||||
CollectionBrowser.routeName,
|
||||
arguments: CollectionBrowserArguments(c),
|
||||
);
|
||||
},
|
||||
icon: await _getClusterBitmap(
|
||||
_getMarkerSize(cluster.count * 1),
|
||||
text: _getMarkerCountString(cluster.count * 1),
|
||||
color: _getMarkerColor(cluster.count * 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
GoogleMapController? _mapController;
|
||||
|
||||
late final _colorHsl =
|
||||
HSLColor.fromColor(Theme.of(context).colorScheme.primaryContainer);
|
||||
}
|
||||
|
||||
class _PanelContainer extends StatefulWidget {
|
||||
const _PanelContainer({
|
||||
required this.isShow,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _PanelContainerState();
|
||||
|
||||
final bool isShow;
|
||||
final Widget child;
|
||||
}
|
||||
|
||||
class _PanelContainerState extends State<_PanelContainer>
|
||||
with TickerProviderStateMixin {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: k.animationDurationNormal,
|
||||
vsync: this,
|
||||
value: 0,
|
||||
);
|
||||
_animation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _PanelContainer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.isShow != widget.isShow) {
|
||||
if (widget.isShow) {
|
||||
_animationController.animateTo(1);
|
||||
} else {
|
||||
_animationController.animateBack(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MatrixTransition(
|
||||
animation: _animation,
|
||||
onTransform: (animationValue) => Matrix4.identity()
|
||||
..translate(0.0, -(_size.height / 2) * (1 - animationValue), 0.0)
|
||||
..scale(1.0, animationValue, 1.0),
|
||||
child: MeasureSize(
|
||||
onChange: (size) => setState(() {
|
||||
_size = size;
|
||||
}),
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _animation;
|
||||
var _size = Size.zero;
|
||||
}
|
||||
|
||||
class _DateRangeToggle extends StatelessWidget {
|
||||
const _DateRangeToggle();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FloatingActionButton.small(
|
||||
onPressed: () {
|
||||
context.addEvent(const _OpenDataRangeControlPanel());
|
||||
},
|
||||
child: const Icon(Icons.date_range_outlined),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DateRangeControlPanel extends StatelessWidget {
|
||||
const _DateRangeControlPanel();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.elevate(theme.colorScheme.surface, 2),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
color: Colors.black26,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
L10n.global().mapBrowserDateRangeLabel,
|
||||
style: Theme.of(context).listTileTheme.titleTextStyle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _BlocSelector<_DateRangeType>(
|
||||
selector: (state) => state.dateRangeType,
|
||||
builder: (context, dateRangeType) =>
|
||||
DropdownButtonFormField<_DateRangeType>(
|
||||
items: _DateRangeType.values
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(e.toDisplayString()),
|
||||
))
|
||||
.toList(),
|
||||
value: dateRangeType,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
context.addEvent(_SetDateRangeType(value));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _BlocSelector<DateRange>(
|
||||
selector: (state) => state.localDateRange,
|
||||
builder: (context, localDateRange) => _DateField(
|
||||
localDateRange.from!,
|
||||
onChanged: (value) {
|
||||
context.addEvent(_SetLocalDateRange(
|
||||
localDateRange.copyWith(from: value.toDate())));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const Text(" - "),
|
||||
Expanded(
|
||||
child: _BlocSelector<DateRange>(
|
||||
selector: (state) => state.localDateRange,
|
||||
builder: (context, localDateRange) => _DateField(
|
||||
localDateRange.to!,
|
||||
onChanged: (value) {
|
||||
context.addEvent(_SetLocalDateRange(
|
||||
localDateRange.copyWith(to: value.toDate())));
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DateField extends StatefulWidget {
|
||||
const _DateField(
|
||||
this.date, {
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _DateFieldState();
|
||||
|
||||
final Date date;
|
||||
final ValueChanged<DateTime>? onChanged;
|
||||
}
|
||||
|
||||
class _DateFieldState extends State<_DateField> {
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_controller.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _DateField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.date != oldWidget.date) {
|
||||
_controller.text = _stringify(widget.date);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final result = await showDatePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(1970),
|
||||
lastDate: clock.now(),
|
||||
currentDate: widget.date.toLocalDateTime(),
|
||||
);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
widget.onChanged?.call(result);
|
||||
},
|
||||
child: IgnorePointer(
|
||||
child: ExcludeFocus(
|
||||
child: TextFormField(
|
||||
controller: _controller,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _stringify(Date date) {
|
||||
return intl.DateFormat(intl.DateFormat.YEAR_ABBR_MONTH_DAY,
|
||||
Localizations.localeOf(context).languageCode)
|
||||
.format(date.toLocalDateTime());
|
||||
}
|
||||
|
||||
late final _controller = TextEditingController(text: _stringify(widget.date));
|
||||
}
|
||||
|
||||
// Generated in https://mapstyle.withgoogle.com/
|
||||
const _mapStyleNight =
|
||||
'[{"elementType":"geometry","stylers":[{"color":"#242f3e"}]},{"elementType":"labels.text.fill","stylers":[{"color":"#746855"}]},{"elementType":"labels.text.stroke","stylers":[{"color":"#242f3e"}]},{"featureType":"administrative.locality","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"poi","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"poi.park","elementType":"geometry","stylers":[{"color":"#263c3f"}]},{"featureType":"poi.park","elementType":"labels.text.fill","stylers":[{"color":"#6b9a76"}]},{"featureType":"road","elementType":"geometry","stylers":[{"color":"#38414e"}]},{"featureType":"road","elementType":"geometry.stroke","stylers":[{"color":"#212a37"}]},{"featureType":"road","elementType":"labels.text.fill","stylers":[{"color":"#9ca5b3"}]},{"featureType":"road.highway","elementType":"geometry","stylers":[{"color":"#746855"}]},{"featureType":"road.highway","elementType":"geometry.stroke","stylers":[{"color":"#1f2835"}]},{"featureType":"road.highway","elementType":"labels.text.fill","stylers":[{"color":"#f3d19c"}]},{"featureType":"transit","elementType":"geometry","stylers":[{"color":"#2f3948"}]},{"featureType":"transit.station","elementType":"labels.text.fill","stylers":[{"color":"#d59563"}]},{"featureType":"water","elementType":"geometry","stylers":[{"color":"#17263c"}]},{"featureType":"water","elementType":"labels.text.fill","stylers":[{"color":"#515c6d"}]},{"featureType":"water","elementType":"labels.text.stroke","stylers":[{"color":"#17263c"}]}]';
|
|
@ -652,38 +652,62 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
google_maps_flutter:
|
||||
google_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_maps
|
||||
sha256: "555d5d736339b0478e821167ac521c810d7b51c3b2734e6802a9f046b64ea37a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
google_maps_cluster_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_maps_cluster_manager
|
||||
sha256: "36e9a4b2d831c470fc85d692a6c9cec70e0f385d578b9697de5f4de347561b83"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
google_maps_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_maps_flutter
|
||||
sha256: abefcb1e5e5c96bdd8084939dda555257af272c7972902ca46d5631092c1df68
|
||||
sha256: ae66fef3e71261d7df2eff29b2a119e190b2884325ecaa55321b1e17b5504066
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.8"
|
||||
version: "2.5.3"
|
||||
google_maps_flutter_android:
|
||||
dependency: transitive
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: google_maps_flutter_android
|
||||
sha256: "9512c862df77c1f0fa5f445513dd3c57f5996f0a809dccb74e54b690ee4e3a0f"
|
||||
sha256: "256b3c974e415bd17555ceff76a5d0badd2cbfd29febfc23070993358f639550"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.15"
|
||||
version: "2.7.0"
|
||||
google_maps_flutter_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_maps_flutter_ios
|
||||
sha256: a9462a433bf3ebe60aadcf4906d2d6341a270d69d3e0fcaa8eb2b64699fcfb4f
|
||||
sha256: "244b3abc7cb611c4a5a2c5ce5a5f36a0d11bf5ccc3f05535d9baf61115ba9a5a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
version: "2.6.1"
|
||||
google_maps_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: google_maps_flutter_platform_interface
|
||||
sha256: "308f0af138fa78e8224d598d46ca182673874d0ef4d754b7157c073b5b4b8e0d"
|
||||
sha256: c14381cfbe65b27cc129a68a79c045083b0e19afac0c23b3ffb0e44d7c7e0944
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.7"
|
||||
version: "2.5.0"
|
||||
google_maps_flutter_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_maps_flutter_web
|
||||
sha256: "6245721c160d6f531c1ef568cf9bef8d660cd585a982aa75121269030163785a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.4+3"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -765,6 +789,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.5"
|
||||
js_wrapping:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js_wrapping
|
||||
sha256: e385980f7c76a8c1c9a560dfb623b890975841542471eade630b2871d243851c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1341,6 +1373,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.27.7"
|
||||
sanitize_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sanitize_html
|
||||
sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
screen_brightness:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
@ -158,6 +158,9 @@ dependencies:
|
|||
wakelock_plus: ^1.1.1
|
||||
woozy_search: ^2.0.3
|
||||
|
||||
google_maps_flutter: 2.5.3
|
||||
google_maps_cluster_manager: 3.1.0
|
||||
|
||||
dependency_overrides:
|
||||
video_player:
|
||||
git:
|
||||
|
@ -174,6 +177,8 @@ dependency_overrides:
|
|||
url: https://gitlab.com/nc-photos/flutter-plugins
|
||||
ref: video_player-v2.8.6-nc-photos-2
|
||||
path: packages/video_player/video_player_platform_interface
|
||||
google_maps_flutter_android: 2.7.0
|
||||
google_maps_flutter_platform_interface: 2.5.0
|
||||
|
||||
dev_dependencies:
|
||||
test: ^1.22.1
|
||||
|
|
|
@ -9,6 +9,22 @@ class DateRange {
|
|||
this.toBound = TimeRangeBound.exclusive,
|
||||
});
|
||||
|
||||
/// Return a copy of the current instance with some changed fields. Setting
|
||||
/// null is not supported
|
||||
DateRange copyWith({
|
||||
Date? from,
|
||||
TimeRangeBound? fromBound,
|
||||
Date? to,
|
||||
TimeRangeBound? toBound,
|
||||
}) {
|
||||
return DateRange(
|
||||
from: from ?? this.from,
|
||||
fromBound: fromBound ?? this.fromBound,
|
||||
to: to ?? this.to,
|
||||
toBound: toBound ?? this.toBound,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "${fromBound == TimeRangeBound.inclusive ? "[" : "("}"
|
||||
|
|
|
@ -11,6 +11,22 @@ class TimeRange {
|
|||
this.toBound = TimeRangeBound.exclusive,
|
||||
});
|
||||
|
||||
/// Return a copy of the current instance with some changed fields. Setting
|
||||
/// null is not supported
|
||||
TimeRange copyWith({
|
||||
DateTime? from,
|
||||
TimeRangeBound? fromBound,
|
||||
DateTime? to,
|
||||
TimeRangeBound? toBound,
|
||||
}) {
|
||||
return TimeRange(
|
||||
from: from ?? this.from,
|
||||
fromBound: fromBound ?? this.fromBound,
|
||||
to: to ?? this.to,
|
||||
toBound: toBound ?? this.toBound,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "${fromBound == TimeRangeBound.inclusive ? "[" : "("}"
|
||||
|
|
|
@ -131,6 +131,29 @@ class DbLocationGroupResult {
|
|||
final List<DbLocationGroup> countryCode;
|
||||
}
|
||||
|
||||
@toString
|
||||
class DbImageLatLng with EquatableMixin {
|
||||
const DbImageLatLng({
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
required this.fileId,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
lat,
|
||||
lng,
|
||||
fileId,
|
||||
];
|
||||
|
||||
final double lat;
|
||||
final double lng;
|
||||
final int fileId;
|
||||
}
|
||||
|
||||
@genCopyWith
|
||||
@toString
|
||||
class DbFilesSummaryItem with EquatableMixin {
|
||||
|
@ -409,6 +432,15 @@ abstract class NpDb {
|
|||
List<String>? excludeRelativeRoots,
|
||||
});
|
||||
|
||||
/// Return the latitude, longitude and the file id of all files
|
||||
Future<List<DbImageLatLng>> getImageLatLngWithFileIds({
|
||||
required DbAccount account,
|
||||
TimeRange? timeRange,
|
||||
List<String>? includeRelativeRoots,
|
||||
List<String>? excludeRelativeRoots,
|
||||
List<String>? mimes,
|
||||
});
|
||||
|
||||
Future<List<DbNcAlbum>> getNcAlbums({
|
||||
required DbAccount account,
|
||||
});
|
||||
|
|
|
@ -124,6 +124,13 @@ extension _$DbLocationGroupResultToString on DbLocationGroupResult {
|
|||
}
|
||||
}
|
||||
|
||||
extension _$DbImageLatLngToString on DbImageLatLng {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "DbImageLatLng {lat: ${lat.toStringAsFixed(3)}, lng: ${lng.toStringAsFixed(3)}, fileId: $fileId}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$DbFilesSummaryItemToString on DbFilesSummaryItem {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
|
|
|
@ -16,7 +16,82 @@ class ImageLocationGroup {
|
|||
final DateTime latestDateTime;
|
||||
}
|
||||
|
||||
class ImageLatLng {
|
||||
const ImageLatLng({
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
required this.fileId,
|
||||
});
|
||||
|
||||
final double lat;
|
||||
final double lng;
|
||||
final int fileId;
|
||||
}
|
||||
|
||||
extension SqliteDbImageLocationExtension on SqliteDb {
|
||||
Future<List<ImageLatLng>> queryImageLatLngWithFileIds({
|
||||
required ByAccount account,
|
||||
TimeRange? timeRange,
|
||||
List<String>? includeRelativeRoots,
|
||||
List<String>? includeRelativeDirs,
|
||||
List<String>? excludeRelativeRoots,
|
||||
List<String>? mimes,
|
||||
}) async {
|
||||
_log.info("[queryImageLatLngWithFileIds] timeRange: $timeRange");
|
||||
final query = _queryFiles().let((q) {
|
||||
q
|
||||
..setQueryMode(
|
||||
FilesQueryMode.expression,
|
||||
expressions: [files.fileId],
|
||||
)
|
||||
..setExtraJoins([
|
||||
innerJoin(
|
||||
imageLocations,
|
||||
imageLocations.accountFile.equalsExp(accountFiles.rowId),
|
||||
),
|
||||
])
|
||||
..setAccount(account);
|
||||
if (includeRelativeRoots != null) {
|
||||
if (includeRelativeRoots.none((p) => p.isEmpty)) {
|
||||
for (final r in includeRelativeRoots) {
|
||||
q.byOrRelativePathPattern("$r/%");
|
||||
}
|
||||
}
|
||||
}
|
||||
return q.build();
|
||||
});
|
||||
query.addColumns([
|
||||
imageLocations.latitude,
|
||||
imageLocations.longitude,
|
||||
]);
|
||||
if (excludeRelativeRoots != null) {
|
||||
for (final r in excludeRelativeRoots) {
|
||||
query.where(accountFiles.relativePath.like("$r/%").not());
|
||||
}
|
||||
}
|
||||
if (mimes != null) {
|
||||
query.where(files.contentType.isIn(mimes));
|
||||
} else {
|
||||
query.where(files.isCollection.isNotValue(true));
|
||||
}
|
||||
if (timeRange != null) {
|
||||
accountFiles.bestDateTime
|
||||
.isBetweenTimeRange(timeRange)
|
||||
?.let((e) => query.where(e));
|
||||
}
|
||||
query
|
||||
..where(imageLocations.latitude.isNotNull() &
|
||||
imageLocations.longitude.isNotNull())
|
||||
..orderBy([OrderingTerm.desc(accountFiles.bestDateTime)]);
|
||||
return query
|
||||
.map((r) => ImageLatLng(
|
||||
lat: r.read(imageLocations.latitude)!,
|
||||
lng: r.read(imageLocations.longitude)!,
|
||||
fileId: r.read(files.fileId)!,
|
||||
))
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<List<ImageLocationGroup>> groupImageLocationsByName({
|
||||
required ByAccount account,
|
||||
List<String>? includeRelativeRoots,
|
||||
|
|
|
@ -36,6 +36,10 @@ class FilesQueryBuilder {
|
|||
_selectExpressions = expressions;
|
||||
}
|
||||
|
||||
void setExtraJoins(List<Join> joins) {
|
||||
_extraJoins = joins.toList();
|
||||
}
|
||||
|
||||
void setAccount(ByAccount account) {
|
||||
if (account.sqlAccount != null) {
|
||||
assert(_dbAccount == null);
|
||||
|
@ -131,6 +135,7 @@ class FilesQueryBuilder {
|
|||
if (_queryMode == FilesQueryMode.completeFile || _byLocation != null)
|
||||
leftOuterJoin(db.imageLocations,
|
||||
db.imageLocations.accountFile.equalsExp(db.accountFiles.rowId)),
|
||||
if (_extraJoins != null) ..._extraJoins!,
|
||||
]) as JoinedSelectStatement;
|
||||
if (_queryMode == FilesQueryMode.expression) {
|
||||
query.addColumns(_selectExpressions!);
|
||||
|
@ -232,6 +237,7 @@ class FilesQueryBuilder {
|
|||
|
||||
FilesQueryMode _queryMode = FilesQueryMode.file;
|
||||
Iterable<Expression>? _selectExpressions;
|
||||
List<Join>? _extraJoins;
|
||||
|
||||
Account? _sqlAccount;
|
||||
DbAccount? _dbAccount;
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:io' as io;
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:np_async/np_async.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_collection/np_collection.dart';
|
||||
import 'package:np_common/object_util.dart';
|
||||
|
@ -574,6 +575,30 @@ class NpDbSqlite implements NpDb {
|
|||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DbImageLatLng>> getImageLatLngWithFileIds({
|
||||
required DbAccount account,
|
||||
TimeRange? timeRange,
|
||||
List<String>? includeRelativeRoots,
|
||||
List<String>? excludeRelativeRoots,
|
||||
List<String>? mimes,
|
||||
}) async {
|
||||
final sqlObjs = await _db.use((db) async {
|
||||
return await db.queryImageLatLngWithFileIds(
|
||||
account: ByAccount.db(account),
|
||||
timeRange: timeRange,
|
||||
includeRelativeRoots: includeRelativeRoots,
|
||||
excludeRelativeRoots: excludeRelativeRoots,
|
||||
mimes: mimes,
|
||||
);
|
||||
});
|
||||
return sqlObjs.computeAll((e) => DbImageLatLng(
|
||||
lat: e.lat,
|
||||
lng: e.lng,
|
||||
fileId: e.fileId,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DbNcAlbum>> getNcAlbums({
|
||||
required DbAccount account,
|
||||
|
|
Loading…
Reference in a new issue