mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat(mobile): map view (#3661)
* feat(mobile): map page - add map view * map: add map-markers * feat(map): add relative date filter * fix: do not let users scroll past map bounds * fix: fetch relative date from store to state on init * feat(mobile):re-fetch markers only on filter change * feat(mobile) - asset bottom sheet in map page * feat(mobile): display markers based on bottom sheet scroll * fix: exif-bottom-sheet - rebase conflict * feat(mobile): map-view - strongly typed map page events * feat(map): zoom to asset * chore: dart analyzer fixes * map-page move attribution to top-right * feat(mobile): map view - asset selection handling * feat(mobile): map-view display map in places row * fix: make asset marker icon responsive * optimise map page rebuilds * refactor(mobile): map page * feat(mobile): map-view: Go to location * map-view(mobile): minor refactor * fix(mobile): Handle invalid coords gracefully * small styling --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
104
mobile/lib/utils/color_filter_generator.dart
Normal file
104
mobile/lib/utils/color_filter_generator.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class InvertionFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
const InvertionFilter({super.key, this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: const ColorFilter.matrix(<double>[
|
||||
-1, 0, 0, 0, 255, //
|
||||
0, -1, 0, 0, 255, //
|
||||
0, 0, -1, 0, 255, //
|
||||
0, 0, 0, 1, 0, //
|
||||
]),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -1 - darkest, 1 - brightest, 0 - unchanged
|
||||
class BrightnessFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final double brightness;
|
||||
const BrightnessFilter({super.key, this.child, this.brightness = 0});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: ColorFilter.matrix(
|
||||
_ColorFilterGenerator.brightnessAdjustMatrix(brightness),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -1 - greyscale, 1 - most saturated, 0 - unchanged
|
||||
class SaturationFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final double saturation;
|
||||
const SaturationFilter({super.key, this.child, this.saturation = 0});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: ColorFilter.matrix(
|
||||
_ColorFilterGenerator.saturationAdjustMatrix(saturation),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorFilterGenerator {
|
||||
static List<double> brightnessAdjustMatrix(double value) {
|
||||
value = value * 10;
|
||||
|
||||
if (value == 0) {
|
||||
return [
|
||||
1, 0, 0, 0, 0, //
|
||||
0, 1, 0, 0, 0, //
|
||||
0, 0, 1, 0, 0, //
|
||||
0, 0, 0, 1, 0, //
|
||||
];
|
||||
}
|
||||
|
||||
return List<double>.from(<double>[
|
||||
1, 0, 0, 0, value, 0, 1, 0, 0, value, 0, 0, 1, 0, value, 0, 0, 0, 1, 0, //
|
||||
]).map((i) => i.toDouble()).toList();
|
||||
}
|
||||
|
||||
static List<double> saturationAdjustMatrix(double value) {
|
||||
value = value * 100;
|
||||
|
||||
if (value == 0) {
|
||||
return [
|
||||
1, 0, 0, 0, 0, //
|
||||
0, 1, 0, 0, 0, //
|
||||
0, 0, 1, 0, 0, //
|
||||
0, 0, 0, 1, 0, //
|
||||
];
|
||||
}
|
||||
|
||||
double x =
|
||||
((1 + ((value > 0) ? ((3 * value) / 100) : (value / 100)))).toDouble();
|
||||
double lumR = 0.3086;
|
||||
double lumG = 0.6094;
|
||||
double lumB = 0.082;
|
||||
|
||||
return List<double>.from(<double>[
|
||||
(lumR * (1 - x)) + x, lumG * (1 - x), lumB * (1 - x), //
|
||||
0, 0, //
|
||||
lumR * (1 - x), //
|
||||
(lumG * (1 - x)) + x, //
|
||||
lumB * (1 - x), //
|
||||
0, 0, //
|
||||
lumR * (1 - x), //
|
||||
lumG * (1 - x), //
|
||||
(lumB * (1 - x)) + x, //
|
||||
0, 0, 0, 0, 0, 1, 0, //
|
||||
]).map((i) => i.toDouble()).toList();
|
||||
}
|
||||
}
|
||||
26
mobile/lib/utils/debounce.dart
Normal file
26
mobile/lib/utils/debounce.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Debounce {
|
||||
Debounce(Duration interval) : _interval = interval.inMilliseconds;
|
||||
final int _interval;
|
||||
Timer? _timer;
|
||||
VoidCallback? action;
|
||||
|
||||
void call(VoidCallback? action) {
|
||||
this.action = action;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(Duration(milliseconds: _interval), _callAndRest);
|
||||
}
|
||||
|
||||
void _callAndRest() {
|
||||
action?.call();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
67
mobile/lib/utils/flutter_map_extensions.dart
Normal file
67
mobile/lib/utils/flutter_map_extensions.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
extension MoveByBounds on MapController {
|
||||
// TODO: Remove this in favor of built-in method when upgrading flutter_map to 5.0.0
|
||||
LatLng? centerBoundsWithPadding(
|
||||
LatLng coordinates,
|
||||
Offset offset, {
|
||||
double? zoomLevel,
|
||||
}) {
|
||||
const crs = Epsg3857();
|
||||
final oldCenterPt = crs.latLngToPoint(coordinates, zoomLevel ?? zoom);
|
||||
final mapCenterPoint = _rotatePoint(
|
||||
oldCenterPt,
|
||||
oldCenterPt - CustomPoint(offset.dx, offset.dy),
|
||||
);
|
||||
return crs.pointToLatLng(mapCenterPoint, zoomLevel ?? zoom);
|
||||
}
|
||||
|
||||
CustomPoint<double> _rotatePoint(
|
||||
CustomPoint<double> mapCenter,
|
||||
CustomPoint<double> point, {
|
||||
bool counterRotation = true,
|
||||
}) {
|
||||
final counterRotationFactor = counterRotation ? -1 : 1;
|
||||
|
||||
final m = Matrix4.identity()
|
||||
..translate(mapCenter.x, mapCenter.y)
|
||||
..rotateZ(degToRadian(rotation) * counterRotationFactor)
|
||||
..translate(-mapCenter.x, -mapCenter.y);
|
||||
|
||||
final tp = MatrixUtils.transformPoint(m, Offset(point.x, point.y));
|
||||
|
||||
return CustomPoint(tp.dx, tp.dy);
|
||||
}
|
||||
|
||||
double getTapThresholdForZoomLevel() {
|
||||
const scale = [
|
||||
25000000,
|
||||
15000000,
|
||||
8000000,
|
||||
4000000,
|
||||
2000000,
|
||||
1000000,
|
||||
500000,
|
||||
250000,
|
||||
100000,
|
||||
50000,
|
||||
25000,
|
||||
15000,
|
||||
8000,
|
||||
4000,
|
||||
2000,
|
||||
1000,
|
||||
500,
|
||||
250,
|
||||
100,
|
||||
50,
|
||||
25,
|
||||
10,
|
||||
5,
|
||||
];
|
||||
return scale[math.max(0, math.min(20, zoom.round() + 2))].toDouble() / 6;
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,20 @@ String getThumbnailUrl(
|
||||
final Asset asset, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
return _getThumbnailUrl(asset.remoteId!, type: type);
|
||||
return getThumbnailUrlForRemoteId(asset.remoteId!, type: type);
|
||||
}
|
||||
|
||||
String getThumbnailCacheKey(
|
||||
final Asset asset, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
return _getThumbnailCacheKey(asset.remoteId!, type);
|
||||
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
|
||||
}
|
||||
|
||||
String _getThumbnailCacheKey(final String id, final ThumbnailFormat type) {
|
||||
String getThumbnailCacheKeyForRemoteId(
|
||||
final String id, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
if (type == ThumbnailFormat.WEBP) {
|
||||
return 'thumbnail-image-$id';
|
||||
} else {
|
||||
@@ -32,7 +35,8 @@ String getAlbumThumbnailUrl(
|
||||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
}
|
||||
return _getThumbnailUrl(album.thumbnail.value!.remoteId!, type: type);
|
||||
return getThumbnailUrlForRemoteId(album.thumbnail.value!.remoteId!,
|
||||
type: type,);
|
||||
}
|
||||
|
||||
String getAlbumThumbNailCacheKey(
|
||||
@@ -42,7 +46,10 @@ String getAlbumThumbNailCacheKey(
|
||||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
}
|
||||
return _getThumbnailCacheKey(album.thumbnail.value!.remoteId!, type);
|
||||
return getThumbnailCacheKeyForRemoteId(
|
||||
album.thumbnail.value!.remoteId!,
|
||||
type: type,
|
||||
);
|
||||
}
|
||||
|
||||
String getImageUrl(final Asset asset) {
|
||||
@@ -53,7 +60,7 @@ String getImageCacheKey(final Asset asset) {
|
||||
return '${asset.id}_fullStage';
|
||||
}
|
||||
|
||||
String _getThumbnailUrl(
|
||||
String getThumbnailUrlForRemoteId(
|
||||
final String id, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
|
||||
76
mobile/lib/utils/selection_handlers.dart
Normal file
76
mobile/lib/utils/selection_handlers.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
|
||||
void handleShareAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref
|
||||
.watch(shareServiceProvider)
|
||||
.shareAssets(selection.toList())
|
||||
.then((_) => Navigator.of(buildContext).pop());
|
||||
return const ShareDialog();
|
||||
},
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleArchiveAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection, {
|
||||
bool shouldArchive = true,
|
||||
ToastGravity toastGravity = ToastGravity.BOTTOM,
|
||||
}) async {
|
||||
if (selection.isNotEmpty) {
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.toggleArchive(selection, shouldArchive);
|
||||
|
||||
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
|
||||
final archiveOrLibrary = shouldArchive ? 'archive' : 'library';
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Moved ${selection.length} $assetOrAssets to $archiveOrLibrary',
|
||||
gravity: toastGravity,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleFavoriteAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection, {
|
||||
bool shouldFavorite = true,
|
||||
ToastGravity toastGravity = ToastGravity.BOTTOM,
|
||||
}) async {
|
||||
if (selection.isNotEmpty) {
|
||||
await ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleFavorite(selection, shouldFavorite);
|
||||
|
||||
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
|
||||
final toastMessage = shouldFavorite
|
||||
? 'Added ${selection.length} $assetOrAssets to favorites'
|
||||
: 'Removed ${selection.length} $assetOrAssets from favorites';
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: toastMessage,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user