Merge branch 'map-browser'

This commit is contained in:
Ming Ming 2024-07-21 12:56:05 +08:00
commit 5903b45e11
34 changed files with 1771 additions and 25 deletions

View file

@ -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);

View file

@ -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

View file

@ -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 {

View file

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

View file

@ -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) {

View file

@ -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;

View file

@ -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):

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

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

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

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

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

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

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

View file

@ -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";
}
}
}

View file

@ -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;

View file

@ -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": {

View file

@ -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"
]
}

View file

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

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

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

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

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

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

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

View file

@ -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:

View file

@ -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

View file

@ -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 ? "[" : "("}"

View file

@ -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 ? "[" : "("}"

View file

@ -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,
});

View file

@ -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

View file

@ -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,

View file

@ -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;

View file

@ -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,