1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-27 09:21:05 +02:00

refactor(mobile): maplibre (#6087)

* chore: maplibre gl pubspec

* refactor(wip): maplibre for maps

* refactor(wip): dual pane + location button

* chore: remove flutter_map and deps

* refactor(wip): map zoom to location

* refactor: location picker

* open gallery_viewer on marker tap

* remove detectScaleGesture param

* test: debounce and throttle

* chore: rename get location method

* feat(mobile): Adds gps locator to map prompt for easy geolocation (#6282)

* Refactored get gps coords

* Use var for linter's sake, should handle errors better

* Cleanup

* Fix linter issues

* chore(dep): update maplibre to official lib

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Joshua Herrera <joshua.herrera227@gmail.com>
This commit is contained in:
shenlong 2024-01-15 15:26:13 +00:00 committed by GitHub
parent aa8c54e248
commit e6c0f0e3aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 2782 additions and 2169 deletions

View File

@ -253,7 +253,7 @@
"map_no_assets_in_bounds": "No photos in this area",
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
"map_no_location_permission_title": "Location Permission denied",
"map_settings_dark_mode": "Dark mode",
"map_settings_theme_settings": "Map Theme",
"map_settings_date_range_option_all": "All",
"map_settings_date_range_option_day": "Past 24 hours",
"map_settings_date_range_option_days": "Past {} days",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -28,6 +28,10 @@ PODS:
- Flutter
- isar_flutter_libs (1.0.0):
- Flutter
- MapLibre (5.14.0-pre3)
- maplibre_gl (0.0.1):
- Flutter
- MapLibre (= 5.14.0-pre3)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@ -71,6 +75,7 @@ DEPENDENCIES:
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
@ -86,6 +91,7 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- FMDB
- MapLibre
- ReachabilitySwift
- SAMKeychain
- Toast
@ -115,6 +121,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/integration_test/ios"
isar_flutter_libs:
:path: ".symlinks/plugins/isar_flutter_libs/ios"
maplibre_gl:
:path: ".symlinks/plugins/maplibre_gl/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@ -152,6 +160,8 @@ SPEC CHECKSUMS:
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
integration_test: 13825b8a9334a850581300559b8839134b124670
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02

View File

@ -96,3 +96,9 @@ extension AssetListExtension on Iterable<Asset> {
return this;
}
}
extension SortedByProperty<T> on Iterable<T> {
Iterable<T> sortedByField(Comparable Function(T e) key) {
return sorted((a, b) => key(a).compareTo(key(b)));
}
}

View File

@ -1,67 +0,0 @@
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;
}
}

View File

@ -0,0 +1,20 @@
import 'package:maplibre_gl/maplibre_gl.dart';
extension WithinBounds on LatLngBounds {
/// Checks whether [point] is inside bounds
bool contains(LatLng point) {
final sw = point;
final ne = point;
return containsBounds(LatLngBounds(southwest: sw, northeast: ne));
}
/// Checks whether [bounds] is contained inside bounds
bool containsBounds(LatLngBounds bounds) {
final sw = bounds.southwest;
final ne = bounds.northeast;
return (sw.latitude >= southwest.latitude) &&
(ne.latitude <= northeast.latitude) &&
(sw.longitude >= southwest.longitude) &&
(ne.longitude <= northeast.longitude);
}
}

View File

@ -0,0 +1,71 @@
import 'dart:math';
import 'package:flutter/services.dart';
import 'package:immich_mobile/modules/map/models/map_marker.dart';
import 'package:immich_mobile/modules/map/utils/map_utils.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
extension MapMarkers on MaplibreMapController {
Future<void> addGeoJSONSourceForMarkers(List<MapMarker> markers) async {
return addSource(
MapUtils.defaultSourceId,
GeojsonSourceProperties(
data: MapUtils.generateGeoJsonForMarkers(markers.toList()),
),
);
}
Future<void> reloadAllLayersForMarkers(List<MapMarker> markers) async {
// !! Make sure to remove layers before sources else the native
// maplibre library would crash when removing the source saying that
// the source is still in use
final existingLayers = await getLayerIds();
if (existingLayers.contains(MapUtils.defaultHeatMapLayerId)) {
await removeLayer(MapUtils.defaultHeatMapLayerId);
}
final existingSources = await getSourceIds();
if (existingSources.contains(MapUtils.defaultSourceId)) {
await removeSource(MapUtils.defaultSourceId);
}
await addGeoJSONSourceForMarkers(markers);
await addHeatmapLayer(
MapUtils.defaultSourceId,
MapUtils.defaultHeatMapLayerId,
MapUtils.defaultHeatMapLayerProperties,
);
}
Future<Symbol?> addMarkerAtLatLng(LatLng centre) async {
// no marker is displayed if asset-path is incorrect
try {
final ByteData bytes = await rootBundle.load("assets/location-pin.png");
await addImage("mapMarker", bytes.buffer.asUint8List());
return addSymbol(
SymbolOptions(
geometry: centre,
iconImage: "mapMarker",
iconSize: 0.15,
iconAnchor: "bottom",
),
);
} finally {
// no-op
}
}
Future<LatLngBounds> getBoundsFromPoint(
Point<double> point,
double distance,
) async {
final southWestPx = Point(point.x - distance, point.y + distance);
final northEastPx = Point(point.x + distance, point.y - distance);
final southWest = await toLatLng(southWestPx);
final northEast = await toLatLng(northEastPx);
return LatLngBounds(southwest: southWest, northeast: northEast);
}
}

View File

@ -2,19 +2,18 @@ import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asset_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:latlong2/latlong.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:url_launcher/url_launcher.dart';
class ExifBottomSheet extends HookConsumerWidget {
@ -92,26 +91,14 @@ class ExifBottomSheet extends HookConsumerWidget {
child: LayoutBuilder(
builder: (context, constraints) {
return MapThumbnail(
showAttribution: false,
coords: LatLng(
centre: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
height: 150,
width: constraints.maxWidth,
zoom: 12.0,
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(
exifInfo?.latitude ?? 0,
exifInfo?.longitude ?? 0,
),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
),
),
],
assetMarkerRemoteId: asset.remoteId,
onTap: (tapPosition, latLong) async {
Uri? uri = await createCoordinatesUri();

View File

@ -27,7 +27,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
final bool canDeselect;
final bool? dynamicLayout;
final bool showMultiSelectIndicator;
final void Function(ItemPosition start, ItemPosition end)?
final void Function(Iterable<ItemPosition> itemPositions)?
visibleItemsListener;
final Widget? topWidget;
final bool shrinkWrap;
@ -89,8 +89,10 @@ class ImmichAssetGrid extends HookConsumerWidget {
};
scale.onUpdate = (details) {
scaleFactor.value =
max(min(5.0, baseScaleFactor.value * details.scale), 1.0);
scaleFactor.value = max(
min(5.0, baseScaleFactor.value * details.scale),
1.0,
);
if (7 - scaleFactor.value.toInt() != perRow.value) {
perRow.value = 7 - scaleFactor.value.toInt();
}

View File

@ -32,7 +32,7 @@ class ImmichAssetGridView extends StatefulWidget {
final bool canDeselect;
final bool dynamicLayout;
final bool showMultiSelectIndicator;
final void Function(ItemPosition start, ItemPosition end)?
final void Function(Iterable<ItemPosition> itemPositions)?
visibleItemsListener;
final Widget? topWidget;
final int heroOffset;
@ -421,15 +421,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void _positionListener() {
final values = _itemPositionsListener.itemPositions.value;
final start = values.firstOrNull;
final end = values.lastOrNull;
if (start != null && end != null) {
if (start.index <= end.index) {
widget.visibleItemsListener?.call(start, end);
} else {
widget.visibleItemsListener?.call(end, start);
}
}
widget.visibleItemsListener?.call(values);
}
void _scrollToTop() {

View File

@ -0,0 +1,13 @@
// ignore_for_file: add-copy-with
sealed class MapEvent {
const MapEvent();
}
class MapAssetsInBoundsUpdated extends MapEvent {
final List<String> assetRemoteIds;
const MapAssetsInBoundsUpdated(this.assetRemoteIds);
}
class MapCloseBottomSheet extends MapEvent {}

View File

@ -0,0 +1,39 @@
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
class MapMarker {
final LatLng latLng;
final String assetRemoteId;
MapMarker({
required this.latLng,
required this.assetRemoteId,
});
MapMarker copyWith({
LatLng? latLng,
String? assetRemoteId,
}) {
return MapMarker(
latLng: latLng ?? this.latLng,
assetRemoteId: assetRemoteId ?? this.assetRemoteId,
);
}
MapMarker.fromDto(MapMarkerResponseDto dto)
: latLng = LatLng(dto.lat, dto.lon),
assetRemoteId = dto.id;
@override
String toString() =>
'MapMarker(latLng: $latLng, assetRemoteId: $assetRemoteId)';
@override
bool operator ==(covariant MapMarker other) {
if (identical(this, other)) return true;
return other.latLng == latLng && other.assetRemoteId == assetRemoteId;
}
@override
int get hashCode => latLng.hashCode ^ assetRemoteId.hashCode;
}

View File

@ -1,40 +0,0 @@
import 'package:immich_mobile/shared/models/asset.dart';
enum MapPageEventType {
mapTap,
bottomSheetScrolled,
assetsInBoundUpdated,
zoomToAsset,
zoomToCurrentLocation,
}
class MapPageEventBase {
final MapPageEventType type;
const MapPageEventBase(this.type);
}
class MapPageOnTapEvent extends MapPageEventBase {
const MapPageOnTapEvent() : super(MapPageEventType.mapTap);
}
class MapPageAssetsInBoundUpdated extends MapPageEventBase {
List<Asset> assets;
MapPageAssetsInBoundUpdated(this.assets)
: super(MapPageEventType.assetsInBoundUpdated);
}
class MapPageBottomSheetScrolled extends MapPageEventBase {
Asset? asset;
MapPageBottomSheetScrolled(this.asset)
: super(MapPageEventType.bottomSheetScrolled);
}
class MapPageZoomToAsset extends MapPageEventBase {
Asset? asset;
MapPageZoomToAsset(this.asset) : super(MapPageEventType.zoomToAsset);
}
class MapPageZoomToLocation extends MapPageEventBase {
const MapPageZoomToLocation() : super(MapPageEventType.zoomToCurrentLocation);
}

View File

@ -1,65 +1,71 @@
import 'package:vector_map_tiles/vector_map_tiles.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class MapState {
final bool isDarkTheme;
final ThemeMode themeMode;
final bool showFavoriteOnly;
final bool includeArchived;
final int relativeTime;
final Style? mapStyle;
final bool isLoading;
final bool shouldRefetchMarkers;
final AsyncValue<String> lightStyleFetched;
final AsyncValue<String> darkStyleFetched;
MapState({
this.isDarkTheme = false,
this.themeMode = ThemeMode.system,
this.showFavoriteOnly = false,
this.includeArchived = false,
this.relativeTime = 0,
this.mapStyle,
this.isLoading = false,
this.shouldRefetchMarkers = false,
this.lightStyleFetched = const AsyncLoading(),
this.darkStyleFetched = const AsyncLoading(),
});
MapState copyWith({
bool? isDarkTheme,
ThemeMode? themeMode,
bool? showFavoriteOnly,
bool? includeArchived,
int? relativeTime,
Style? mapStyle,
bool? isLoading,
bool? shouldRefetchMarkers,
AsyncValue<String>? lightStyleFetched,
AsyncValue<String>? darkStyleFetched,
}) {
return MapState(
isDarkTheme: isDarkTheme ?? this.isDarkTheme,
themeMode: themeMode ?? this.themeMode,
showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
includeArchived: includeArchived ?? this.includeArchived,
relativeTime: relativeTime ?? this.relativeTime,
mapStyle: mapStyle ?? this.mapStyle,
isLoading: isLoading ?? this.isLoading,
shouldRefetchMarkers: shouldRefetchMarkers ?? this.shouldRefetchMarkers,
lightStyleFetched: lightStyleFetched ?? this.lightStyleFetched,
darkStyleFetched: darkStyleFetched ?? this.darkStyleFetched,
);
}
@override
String toString() {
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived, mapStyle: $mapStyle, isLoading: $isLoading)';
return 'MapState(themeMode: $themeMode, showFavoriteOnly: $showFavoriteOnly, includeArchived: $includeArchived, relativeTime: $relativeTime, shouldRefetchMarkers: $shouldRefetchMarkers, lightStyleFetched: $lightStyleFetched, darkStyleFetched: $darkStyleFetched)';
}
@override
bool operator ==(Object other) {
bool operator ==(covariant MapState other) {
if (identical(this, other)) return true;
return other is MapState &&
other.isDarkTheme == isDarkTheme &&
return other.themeMode == themeMode &&
other.showFavoriteOnly == showFavoriteOnly &&
other.relativeTime == relativeTime &&
other.includeArchived == includeArchived &&
other.mapStyle == mapStyle &&
other.isLoading == isLoading;
other.relativeTime == relativeTime &&
other.shouldRefetchMarkers == shouldRefetchMarkers &&
other.lightStyleFetched == lightStyleFetched &&
other.darkStyleFetched == darkStyleFetched;
}
@override
int get hashCode {
return isDarkTheme.hashCode ^
return themeMode.hashCode ^
showFavoriteOnly.hashCode ^
relativeTime.hashCode ^
includeArchived.hashCode ^
mapStyle.hashCode ^
isLoading.hashCode;
relativeTime.hashCode ^
shouldRefetchMarkers.hashCode ^
lightStyleFetched.hashCode ^
darkStyleFetched.hashCode;
}
}

View File

@ -1,13 +1,14 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/models/map_marker.dart';
import 'package:immich_mobile/modules/map/providers/map_service.provider.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/modules/map/services/map.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:latlong2/latlong.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
final mapMarkersProvider =
FutureProvider.autoDispose<Set<AssetMarkerData>>((ref) async {
part 'map_marker.provider.g.dart';
@riverpod
Future<List<MapMarker>> mapMarkers(MapMarkersRef ref) async {
final service = ref.read(mapServiceProvider);
final mapState = ref.read(mapStateNotifier);
final mapState = ref.read(mapStateNotifierProvider);
DateTime? fileCreatedAfter;
bool? isFavorite;
bool? isIncludeArchived;
@ -31,34 +32,5 @@ final mapMarkersProvider =
fileCreatedAfter: fileCreatedAfter,
);
final assetMarkerData = await Future.wait(
markers.map((e) async {
final asset = await service.getAssetForMarkerId(e.id);
bool hasInvalidCoords = e.lat < -90 || e.lat > 90;
hasInvalidCoords = hasInvalidCoords || (e.lon < -180 || e.lon > 180);
if (asset == null || hasInvalidCoords) return null;
return AssetMarkerData(asset, LatLng(e.lat, e.lon));
}),
);
return assetMarkerData.nonNulls.toSet();
});
class AssetMarkerData {
final LatLng point;
final Asset asset;
const AssetMarkerData(this.asset, this.point);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AssetMarkerData && other.asset.remoteId == asset.remoteId;
}
@override
int get hashCode {
return asset.remoteId.hashCode;
}
return markers.toList();
}

Binary file not shown.

View File

@ -0,0 +1,9 @@
import 'package:immich_mobile/modules/map/services/map.service.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'map_service.provider.g.dart';
@riverpod
MapSerivce mapService(MapServiceRef ref) =>
MapSerivce(ref.watch(apiServiceProvider));

Binary file not shown.

View File

@ -1,159 +1,138 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:vector_map_tiles/vector_map_tiles.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class MapStateNotifier extends StateNotifier<MapState> {
MapStateNotifier(this._appSettingsProvider, this._apiService)
: super(
MapState(
isDarkTheme: _appSettingsProvider
.getSetting<bool>(AppSettingsEnum.mapThemeMode),
showFavoriteOnly: _appSettingsProvider
.getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
includeArchived: _appSettingsProvider
.getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
relativeTime: _appSettingsProvider
.getSetting<int>(AppSettingsEnum.mapRelativeDate),
isLoading: true,
),
) {
_fetchStyleFromServer(
_appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapThemeMode),
part 'map_state.provider.g.dart';
@Riverpod(keepAlive: true)
class MapStateNotifier extends _$MapStateNotifier {
final _log = Logger("MapStateNotifier");
@override
MapState build() {
final appSettingsProvider = ref.read(appSettingsServiceProvider);
// Fetch and save the Style JSONs
loadStyles();
return MapState(
themeMode: ThemeMode.values[
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapThemeMode)],
showFavoriteOnly: appSettingsProvider
.getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
includeArchived: appSettingsProvider
.getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
relativeTime:
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapRelativeDate),
);
}
final AppSettingsService _appSettingsProvider;
final ApiService _apiService;
final Logger _log = Logger("MapStateNotifier");
void loadStyles() async {
final documents = (await getApplicationDocumentsDirectory()).path;
bool get isRaster =>
state.mapStyle != null && state.mapStyle!.rasterTileProvider != null;
// Set to loading
state = state.copyWith(lightStyleFetched: const AsyncLoading());
double get maxZoom =>
(isRaster ? state.mapStyle!.rasterTileProvider!.maximumZoom : 18)
.toDouble();
// Fetch and save light theme
final lightResponse = await ref
.read(apiServiceProvider)
.systemConfigApi
.getMapStyleWithHttpInfo(MapTheme.light);
void switchTheme(bool isDarkTheme) {
_updateThemeMode(isDarkTheme);
_fetchStyleFromServer(isDarkTheme);
}
void _updateThemeMode(bool isDarkTheme) {
_appSettingsProvider.setSetting(
AppSettingsEnum.mapThemeMode,
isDarkTheme,
);
state = state.copyWith(isDarkTheme: isDarkTheme, isLoading: true);
}
void _fetchStyleFromServer(bool isDarkTheme) async {
final styleResponse = await _apiService.systemConfigApi
.getMapStyleWithHttpInfo(isDarkTheme ? MapTheme.dark : MapTheme.light);
if (styleResponse.statusCode >= HttpStatus.badRequest) {
throw ApiException(styleResponse.statusCode, styleResponse.body);
}
final styleJsonString = styleResponse.body.isNotEmpty &&
styleResponse.statusCode != HttpStatus.noContent
? styleResponse.body
: null;
if (styleJsonString == null) {
_log.severe('Style JSON from server is empty');
if (lightResponse.statusCode >= HttpStatus.badRequest) {
state = state.copyWith(
lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
);
_log.severe(
"Cannot fetch map light style with status - ${lightResponse.statusCode} and response - ${lightResponse.body}",
);
return;
}
final styleJson = await compute(jsonDecode, styleJsonString);
if (styleJson is! Map<String, dynamic>) {
_log.severe('Style JSON from server is invalid');
final lightJSON = lightResponse.body;
final lightFile = await File("$documents/map-style-light.json")
.writeAsString(lightJSON, flush: true);
// Update state with path
state =
state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path));
// Set to loading
state = state.copyWith(darkStyleFetched: const AsyncLoading());
// Fetch and save dark theme
final darkResponse = await ref
.read(apiServiceProvider)
.systemConfigApi
.getMapStyleWithHttpInfo(MapTheme.dark);
if (darkResponse.statusCode >= HttpStatus.badRequest) {
state = state.copyWith(
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
);
_log.severe(
"Cannot fetch map dark style with status - ${darkResponse.statusCode} and response - ${darkResponse.body}",
);
return;
}
final styleReader = StyleReader(uri: '');
Style? style;
try {
style = await styleReader.readFromMap(styleJson);
} finally {
// Consume all error
}
state = state.copyWith(
mapStyle: style,
isLoading: false,
);
final darkJSON = darkResponse.body;
final darkFile = await File("$documents/map-style-dark.json")
.writeAsString(darkJSON, flush: true);
// Update state with path
state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path));
}
void switchTheme(ThemeMode mode) {
ref.read(appSettingsServiceProvider).setSetting(
AppSettingsEnum.mapThemeMode,
mode.index,
);
state = state.copyWith(themeMode: mode);
}
void switchFavoriteOnly(bool isFavoriteOnly) {
_appSettingsProvider.setSetting(
AppSettingsEnum.mapShowFavoriteOnly,
isFavoriteOnly,
ref.read(appSettingsServiceProvider).setSetting(
AppSettingsEnum.mapShowFavoriteOnly,
isFavoriteOnly,
);
state = state.copyWith(
showFavoriteOnly: isFavoriteOnly,
shouldRefetchMarkers: true,
);
state = state.copyWith(showFavoriteOnly: isFavoriteOnly);
}
void setRefetchMarkers(bool shouldRefetch) {
state = state.copyWith(shouldRefetchMarkers: shouldRefetch);
}
void switchIncludeArchived(bool isIncludeArchived) {
_appSettingsProvider.setSetting(
AppSettingsEnum.mapIncludeArchived,
isIncludeArchived,
ref.read(appSettingsServiceProvider).setSetting(
AppSettingsEnum.mapIncludeArchived,
isIncludeArchived,
);
state = state.copyWith(
includeArchived: isIncludeArchived,
shouldRefetchMarkers: true,
);
state = state.copyWith(includeArchived: isIncludeArchived);
}
void setRelativeTime(int relativeTime) {
_appSettingsProvider.setSetting(
AppSettingsEnum.mapRelativeDate,
relativeTime,
ref.read(appSettingsServiceProvider).setSetting(
AppSettingsEnum.mapRelativeDate,
relativeTime,
);
state = state.copyWith(
relativeTime: relativeTime,
shouldRefetchMarkers: true,
);
state = state.copyWith(relativeTime: relativeTime);
}
Widget getTileLayer([bool forceDark = false]) {
if (isRaster) {
final rasterProvider = state.mapStyle!.rasterTileProvider;
final rasterLayer = TileLayer(
urlTemplate: rasterProvider!.url,
maxNativeZoom: rasterProvider.maximumZoom,
maxZoom: rasterProvider.maximumZoom.toDouble(),
);
return state.isDarkTheme || forceDark
? InvertionFilter(
child: SaturationFilter(
saturation: -1,
child: BrightnessFilter(
brightness: -1,
child: rasterLayer,
),
),
)
: rasterLayer;
}
if (state.mapStyle != null && !isRaster) {
return VectorTileLayer(
// Tiles and themes will be set for vector providers
tileProviders: state.mapStyle!.providers!,
theme: state.mapStyle!.theme!,
sprites: state.mapStyle!.sprites,
concurrency: 6,
);
}
return const Center(child: ImmichLoadingIndicator());
}
}
final mapStateNotifier =
StateNotifierProvider<MapStateNotifier, MapState>((ref) {
return MapStateNotifier(
ref.watch(appSettingsServiceProvider),
ref.watch(apiServiceProvider),
);
});

Binary file not shown.

View File

@ -1,62 +1,33 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
import 'package:immich_mobile/modules/map/models/map_marker.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final mapServiceProvider = Provider(
(ref) => MapSerivce(
ref.read(apiServiceProvider),
ref.read(dbProvider),
),
);
class MapSerivce {
class MapSerivce with ErrorLoggerMixin {
final ApiService _apiService;
final Isar _db;
final _log = Logger("MapService");
@override
final logger = Logger("MapService");
MapSerivce(this._apiService, this._db);
MapSerivce(this._apiService);
Future<List<MapMarkerResponseDto>> getMapMarkers({
Future<Iterable<MapMarker>> getMapMarkers({
bool? isFavorite,
bool? withArchived,
DateTime? fileCreatedAfter,
DateTime? fileCreatedBefore,
}) async {
try {
final markers = await _apiService.assetApi.getMapMarkers(
isFavorite: isFavorite,
isArchived: withArchived,
fileCreatedAfter: fileCreatedAfter,
fileCreatedBefore: fileCreatedBefore,
);
return logError(
() async {
final markers = await _apiService.assetApi.getMapMarkers(
isFavorite: isFavorite,
isArchived: withArchived,
fileCreatedAfter: fileCreatedAfter,
fileCreatedBefore: fileCreatedBefore,
);
return markers ?? [];
} catch (error, stack) {
_log.severe("Cannot get map markers ${error.toString()}", error, stack);
return [];
}
}
Future<Asset?> getAssetForMarkerId(String remoteId) async {
try {
final assets = await _db.assets.getAllByRemoteId([remoteId]);
if (assets.isNotEmpty) return assets[0];
final dto = await _apiService.assetApi.getAssetById(remoteId);
if (dto == null) return null;
return _db.assets.getByRemoteId(dto.id);
} catch (error, stack) {
_log.severe(
"Cannot get asset for marker ${error.toString()}",
error,
stack,
);
return null;
}
return markers?.map(MapMarker.fromDto) ?? [];
},
defaultValue: [],
);
}
}

View File

@ -1,30 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
class LocationServiceDisabledDialog extends ConfirmDialog {
LocationServiceDisabledDialog({Key? key})
: super(
key: key,
title: 'map_location_service_disabled_title'.tr(),
content: 'map_location_service_disabled_content'.tr(),
cancel: 'map_location_dialog_cancel'.tr(),
ok: 'map_location_dialog_yes'.tr(),
onOk: () async {
await Geolocator.openLocationSettings();
},
);
}
class LocationPermissionDisabledDialog extends ConfirmDialog {
LocationPermissionDisabledDialog({Key? key})
: super(
key: key,
title: 'map_no_location_permission_title'.tr(),
content: 'map_no_location_permission_content'.tr(),
cancel: 'map_location_dialog_cancel'.tr(),
ok: 'map_location_dialog_yes'.tr(),
onOk: () {},
);
}

View File

@ -1,114 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:latlong2/latlong.dart';
class MapLocationPickerPage extends HookConsumerWidget {
final LatLng? initialLatLng;
const MapLocationPickerPage({super.key, this.initialLatLng});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedLatLng = useState<LatLng>(initialLatLng ?? LatLng(0, 0));
final isDarkTheme =
ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
final isLoading =
ref.watch(mapStateNotifier.select((state) => state.isLoading));
final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom;
return Theme(
// Override app theme based on map theme
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
child: Scaffold(
extendBodyBehindAppBar: true,
body: Stack(
children: [
if (!isLoading)
FlutterMap(
options: MapOptions(
maxBounds:
LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
interactiveFlags: InteractiveFlag.doubleTapZoom |
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchMove |
InteractiveFlag.pinchZoom,
center: LatLng(20, 20),
zoom: 2,
minZoom: 1,
maxZoom: maxZoom,
onTap: (tapPosition, point) => selectedLatLng.value = point,
),
children: [
ref.read(mapStateNotifier.notifier).getTileLayer(),
MarkerLayer(
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: selectedLatLng.value,
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
),
height: 40,
width: 40,
),
],
),
],
),
if (isLoading)
Positioned(
top: context.height * 0.35,
left: context.width * 0.425,
child: const ImmichLoadingIndicator(),
),
],
),
bottomSheet: BottomSheet(
onClosing: () {},
builder: (context) => SizedBox(
height: 150,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"${selectedLatLng.value.latitude.toStringAsFixed(4)}, ${selectedLatLng.value.longitude.toStringAsFixed(4)}",
style: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
fontWeight: FontWeight.w600,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () => context.popRoute(selectedLatLng.value),
child: const Text("map_location_picker_page_use_location")
.tr(),
),
ElevatedButton(
onPressed: () => context.popRoute(),
style: ElevatedButton.styleFrom(
backgroundColor: context.colorScheme.error,
),
child: const Text("action_common_cancel").tr(),
),
],
),
],
),
),
),
),
);
}
}

View File

@ -1,138 +0,0 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/disable_multi_select_button.dart';
import 'package:immich_mobile/modules/map/ui/map_settings_dialog.dart';
class MapAppBar extends HookWidget implements PreferredSizeWidget {
final ValueNotifier<bool> selectionEnabled;
final int selectedAssetsLength;
final bool isDarkTheme;
final void Function() onShare;
final void Function() onFavorite;
final void Function() onArchive;
const MapAppBar({
super.key,
required this.selectionEnabled,
required this.selectedAssetsLength,
required this.onShare,
required this.onArchive,
required this.onFavorite,
this.isDarkTheme = false,
});
List<Widget> buildNonSelectionWidgets(BuildContext context) {
return [
Padding(
padding: const EdgeInsets.only(left: 15, top: 15),
child: ElevatedButton(
onPressed: () => context.popRoute(),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 22),
),
),
Padding(
padding: const EdgeInsets.only(right: 15, top: 15),
child: ElevatedButton(
onPressed: () => showDialog(
context: context,
builder: (BuildContext _) {
return const MapSettingsDialog();
},
),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(Icons.more_vert_rounded, size: 22),
),
),
];
}
List<Widget> buildSelectionWidgets() {
return [
DisableMultiSelectButton(
onPressed: () {
selectionEnabled.value = false;
},
selectedItemCount: selectedAssetsLength,
),
Row(
children: [
// Share button
Padding(
padding: const EdgeInsets.only(top: 15),
child: ElevatedButton(
onPressed: onShare,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: Icon(
Platform.isAndroid
? Icons.share_rounded
: Icons.ios_share_rounded,
size: 22,
),
),
),
// Favorite button
Padding(
padding: const EdgeInsets.only(top: 15),
child: ElevatedButton(
onPressed: onFavorite,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(
Icons.favorite,
size: 22,
),
),
),
// Archive Button
Padding(
padding: const EdgeInsets.only(right: 10, top: 15),
child: ElevatedButton(
onPressed: onArchive,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(
Icons.archive,
size: 22,
),
),
),
],
),
];
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (!selectionEnabled.value) ...buildNonSelectionWidgets(context),
if (selectionEnabled.value) ...buildSelectionWidgets(),
],
),
);
}
@override
Size get preferredSize => const Size.fromHeight(100);
}

View File

@ -1,356 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class MapPageBottomSheet extends StatefulHookConsumerWidget {
final Stream mapPageEventStream;
final StreamController bottomSheetEventSC;
final bool selectionEnabled;
final ImmichAssetGridSelectionListener selectionlistener;
final bool isDarkTheme;
const MapPageBottomSheet({
super.key,
required this.mapPageEventStream,
required this.bottomSheetEventSC,
required this.selectionEnabled,
required this.selectionlistener,
this.isDarkTheme = false,
});
@override
AssetsInBoundBottomSheetState createState() =>
AssetsInBoundBottomSheetState();
}
class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
// Non-State variables
bool userTappedOnMap = false;
RenderList? _cachedRenderList;
int assetOffsetInSheet = -1;
late final DraggableScrollableController bottomSheetController;
late final Debounce debounce;
@override
void initState() {
super.initState();
bottomSheetController = DraggableScrollableController();
debounce = Debounce(
const Duration(milliseconds: 100),
);
}
@override
Widget build(BuildContext context) {
final isDarkTheme = context.isDarkTheme;
final bottomPadding =
Platform.isAndroid ? MediaQuery.of(context).padding.bottom - 10 : 0.0;
final maxHeight = context.height - bottomPadding;
final isSheetScrolled = useState(false);
final isSheetExpanded = useState(false);
final assetsInBound = useState(<Asset>[]);
final currentExtend = useState(0.1);
void handleMapPageEvents(dynamic event) {
if (event is MapPageAssetsInBoundUpdated) {
assetsInBound.value = event.assets;
} else if (event is MapPageOnTapEvent) {
userTappedOnMap = true;
assetOffsetInSheet = -1;
bottomSheetController.animateTo(
0.1,
duration: const Duration(milliseconds: 200),
curve: Curves.linearToEaseOut,
);
isSheetScrolled.value = false;
}
}
useEffect(
() {
final mapPageEventSubscription =
widget.mapPageEventStream.listen(handleMapPageEvents);
return mapPageEventSubscription.cancel;
},
[widget.mapPageEventStream],
);
void handleVisibleItems(ItemPosition start, ItemPosition end) {
final renderElement = _cachedRenderList?.elements[start.index];
if (renderElement == null) {
return;
}
final rowOffset = renderElement.offset;
if ((-start.itemLeadingEdge) != 0) {
var columnOffset = -start.itemLeadingEdge ~/ 0.05;
columnOffset = columnOffset < renderElement.totalCount
? columnOffset
: renderElement.totalCount - 1;
assetOffsetInSheet = rowOffset + columnOffset;
final asset = _cachedRenderList?.allAssets?[assetOffsetInSheet];
userTappedOnMap = false;
if (!userTappedOnMap && isSheetExpanded.value) {
widget.bottomSheetEventSC.add(
MapPageBottomSheetScrolled(asset),
);
}
if (isSheetExpanded.value) {
isSheetScrolled.value = true;
}
}
}
void visibleItemsListener(ItemPosition start, ItemPosition end) {
if (_cachedRenderList == null) {
debounce.dispose();
return;
}
debounce.call(() => handleVisibleItems(start, end));
}
Widget buildNoPhotosWidget() {
const image = Image(
image: AssetImage('assets/lighthouse.png'),
);
return isSheetExpanded.value
? Column(
children: [
const SizedBox(
height: 80,
),
SizedBox(
height: 150,
width: 150,
child: isDarkTheme
? const InvertionFilter(
child: SaturationFilter(
saturation: -1,
child: BrightnessFilter(
brightness: -5,
child: image,
),
),
)
: image,
),
const SizedBox(
height: 20,
),
Text(
"map_zoom_to_see_photos".tr(),
style: TextStyle(
fontSize: 20,
color: context.textTheme.displayLarge?.color,
),
),
],
)
: const SizedBox.shrink();
}
void onTapMapButton() {
if (assetOffsetInSheet != -1) {
widget.bottomSheetEventSC.add(
MapPageZoomToAsset(
_cachedRenderList?.allAssets?[assetOffsetInSheet],
),
);
}
}
Widget buildDragHandle(ScrollController scrollController) {
final textToDisplay = assetsInBound.value.isNotEmpty
? "map_assets_in_bounds"
.tr(args: [assetsInBound.value.length.toString()])
: "map_no_assets_in_bounds".tr();
final dragHandle = Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
color: isDarkTheme ? Colors.grey[900] : Colors.grey[100],
),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 5),
const CustomDraggingHandle(),
const SizedBox(height: 15),
Text(
textToDisplay,
style: context.textTheme.bodyLarge,
),
Divider(
height: 10,
color:
context.textTheme.displayLarge?.color?.withOpacity(0.5),
),
],
),
if (isSheetExpanded.value && isSheetScrolled.value)
Positioned(
top: 5,
right: 10,
child: IconButton(
icon: Icon(
Icons.map_outlined,
color: context.textTheme.displayLarge?.color,
),
iconSize: 20,
tooltip: 'Zoom to bounds',
onPressed: onTapMapButton,
),
),
],
),
);
return SingleChildScrollView(
controller: scrollController,
physics: const ClampingScrollPhysics(),
child: dragHandle,
);
}
return NotificationListener<DraggableScrollableNotification>(
onNotification: (DraggableScrollableNotification notification) {
final sheetExtended = notification.extent > 0.2;
isSheetExpanded.value = sheetExtended;
currentExtend.value = notification.extent;
if (!sheetExtended) {
// reset state
userTappedOnMap = false;
assetOffsetInSheet = -1;
isSheetScrolled.value = false;
}
return true;
},
child: Padding(
padding: EdgeInsets.only(
bottom: bottomPadding,
),
child: Stack(
children: [
DraggableScrollableSheet(
controller: bottomSheetController,
initialChildSize: 0.1,
minChildSize: 0.1,
maxChildSize: 0.55,
snap: true,
builder: (
BuildContext context,
ScrollController scrollController,
) {
return Card(
color: isDarkTheme ? Colors.grey[900] : Colors.grey[100],
surfaceTintColor: Colors.transparent,
elevation: 18.0,
margin: const EdgeInsets.all(0),
child: Column(
children: [
buildDragHandle(scrollController),
if (isSheetExpanded.value &&
assetsInBound.value.isNotEmpty)
ref
.watch(
renderListProvider(
assetsInBound.value,
),
)
.when(
data: (renderList) {
_cachedRenderList = renderList;
final assetGrid = ImmichAssetGrid(
shrinkWrap: true,
renderList: renderList,
showDragScroll: false,
selectionActive: widget.selectionEnabled,
showMultiSelectIndicator: false,
listener: widget.selectionlistener,
visibleItemsListener: visibleItemsListener,
);
return Expanded(child: assetGrid);
},
error: (error, stackTrace) {
log.warning(
"Cannot get assets in the current map bounds ${error.toString()}",
error,
stackTrace,
);
return const SizedBox.shrink();
},
loading: () => const SizedBox.shrink(),
),
if (isSheetExpanded.value && assetsInBound.value.isEmpty)
Expanded(
child: SingleChildScrollView(
child: buildNoPhotosWidget(),
),
),
],
),
);
},
),
Positioned(
bottom: maxHeight * currentExtend.value,
left: 0,
child: ColoredBox(
color:
(widget.isDarkTheme ? Colors.grey[900] : Colors.grey[100])!,
child: Padding(
padding: const EdgeInsets.all(3),
child: Text(
'OpenStreetMap contributors',
style: TextStyle(
fontSize: 6,
color: !widget.isDarkTheme
? Colors.grey[900]
: Colors.grey[100],
),
),
),
),
),
Positioned(
bottom: maxHeight * (0.14 + (currentExtend.value - 0.1)),
right: 15,
child: ElevatedButton(
onPressed: () => widget.bottomSheetEventSC
.add(const MapPageZoomToLocation()),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(12),
),
child: const Icon(
Icons.my_location,
size: 22,
fill: 1,
),
),
),
],
),
),
);
}
}

View File

@ -1,228 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
class MapSettingsDialog extends HookConsumerWidget {
const MapSettingsDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapSettingsNotifier = ref.read(mapStateNotifier.notifier);
final mapSettings = ref.read(mapStateNotifier);
final isDarkMode = useState(mapSettings.isDarkTheme);
final showFavoriteOnly = useState(mapSettings.showFavoriteOnly);
final showIncludeArchived = useState(mapSettings.includeArchived);
final showRelativeDate = useState(mapSettings.relativeTime);
final ThemeData theme = context.themeData;
Widget buildMapThemeSetting() {
return SwitchListTile.adaptive(
value: isDarkMode.value,
onChanged: (value) {
isDarkMode.value = value;
},
activeColor: theme.primaryColor,
dense: true,
title: Text(
"map_settings_dark_mode".tr(),
style:
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
Widget buildFavoriteOnlySetting() {
return SwitchListTile.adaptive(
value: showFavoriteOnly.value,
onChanged: (value) {
showFavoriteOnly.value = value;
},
activeColor: theme.primaryColor,
dense: true,
title: Text(
"map_settings_only_show_favorites".tr(),
style:
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
Widget buildIncludeArchivedSetting() {
return SwitchListTile.adaptive(
value: showIncludeArchived.value,
onChanged: (value) {
showIncludeArchived.value = value;
},
activeColor: theme.primaryColor,
dense: true,
title: Text(
"map_settings_include_show_archived".tr(),
style:
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
),
);
}
Widget buildDateRangeSetting() {
final now = DateTime.now();
return DropdownMenu(
enableSearch: false,
enableFilter: false,
initialSelection: showRelativeDate.value,
onSelected: (value) {
showRelativeDate.value = value!;
},
dropdownMenuEntries: [
DropdownMenuEntry(
value: 0,
label: "map_settings_date_range_option_all".tr(),
),
DropdownMenuEntry(
value: 1,
label: "map_settings_date_range_option_day".tr(),
),
DropdownMenuEntry(
value: 7,
label: "map_settings_date_range_option_days".tr(
args: ["7"],
),
),
DropdownMenuEntry(
value: 30,
label: "map_settings_date_range_option_days".tr(
args: ["30"],
),
),
DropdownMenuEntry(
value: now
.difference(
DateTime(
now.year - 1,
now.month,
now.day,
now.hour,
now.minute,
now.second,
),
)
.inDays,
label: "map_settings_date_range_option_year".tr(),
),
DropdownMenuEntry(
value: now
.difference(
DateTime(
now.year - 3,
now.month,
now.day,
now.hour,
now.minute,
now.second,
),
)
.inDays,
label: "map_settings_date_range_option_years".tr(args: ["3"]),
),
],
);
}
List<Widget> getDialogActions() {
return <Widget>[
TextButton(
onPressed: () => context.pop(),
style: TextButton.styleFrom(
backgroundColor:
mapSettings.isDarkTheme ? Colors.grey[100] : Colors.grey[700],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
"map_settings_dialog_cancel".tr(),
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w500,
color: mapSettings.isDarkTheme
? Colors.grey[900]
: Colors.grey[100],
),
),
),
),
TextButton(
onPressed: () {
mapSettingsNotifier.switchTheme(isDarkMode.value);
mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value);
mapSettingsNotifier.setRelativeTime(showRelativeDate.value);
mapSettingsNotifier
.switchIncludeArchived(showIncludeArchived.value);
context.pop();
},
style: TextButton.styleFrom(
backgroundColor: theme.primaryColor,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
"map_settings_dialog_save".tr(),
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w500,
color: theme.primaryTextTheme.labelLarge?.color,
),
),
),
),
];
}
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
title: Center(
child: Text(
"map_settings_dialog_title".tr(),
style: TextStyle(
color: theme.primaryColor,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: context.height * 0.6,
),
child: ListView(
shrinkWrap: true,
children: [
buildMapThemeSetting(),
buildFavoriteOnlySetting(),
buildIncludeArchivedSetting(),
const SizedBox(
height: 10,
),
Padding(
padding: const EdgeInsets.only(left: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"map_settings_only_relative_range".tr(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
buildDateRangeSetting(),
],
),
),
].toList(),
),
),
),
actions: getDialogActions(),
actionsAlignment: MainAxisAlignment.spaceEvenly,
);
}
}

View File

@ -1,86 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/modules/map/utils/map_controller_hook.dart';
import 'package:latlong2/latlong.dart';
import 'package:url_launcher/url_launcher.dart';
// A non-interactive thumbnail of a map in the given coordinates with optional markers
class MapThumbnail extends HookConsumerWidget {
final Function(TapPosition, LatLng)? onTap;
final LatLng coords;
final double zoom;
final List<Marker> markers;
final double height;
final double width;
final bool showAttribution;
final bool isDarkTheme;
const MapThumbnail({
super.key,
required this.coords,
this.height = 100,
this.width = 100,
this.onTap,
this.zoom = 1,
this.showAttribution = true,
this.isDarkTheme = false,
this.markers = const [],
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapController = useMapController();
final isMapReady = useRef(false);
ref.watch(mapStateNotifier.select((s) => s.mapStyle));
useEffect(
() {
if (isMapReady.value && mapController.center != coords) {
mapController.move(coords, zoom);
}
return null;
},
[coords],
);
return SizedBox(
height: height,
width: width,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: FlutterMap(
mapController: mapController,
options: MapOptions(
interactiveFlags: InteractiveFlag.none,
center: coords,
zoom: zoom,
onTap: onTap,
onMapReady: () => isMapReady.value = true,
),
nonRotatedChildren: [
if (showAttribution)
RichAttributionWidget(
animationConfig: const ScaleRAWA(),
attributions: [
TextSourceAttribution(
'OpenStreetMap contributors',
onTap: () => launchUrl(
Uri.parse('https://openstreetmap.org/copyright'),
mode: LaunchMode.externalApplication,
),
),
],
),
],
children: [
ref.read(mapStateNotifier.notifier).getTileLayer(isDarkTheme),
if (markers.isNotEmpty) MarkerLayer(markers: markers),
],
),
),
);
}
}

View File

@ -1,32 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_map/flutter_map.dart';
MapController useMapController({
String? debugLabel,
List<Object?>? keys,
}) {
return use(_MapControllerHook(keys: keys));
}
class _MapControllerHook extends Hook<MapController> {
const _MapControllerHook({List<Object?>? keys}) : super(keys: keys);
@override
HookState<MapController, Hook<MapController>> createState() =>
_MapControllerHookState();
}
class _MapControllerHookState
extends HookState<MapController, _MapControllerHook> {
late final controller = MapController();
@override
MapController build(BuildContext context) => controller;
@override
void dispose() => controller.dispose();
@override
String get debugLabel => 'useMapController';
}

View File

@ -0,0 +1,138 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/map/models/map_marker.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:geolocator/geolocator.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class MapUtils {
MapUtils._();
static final Logger _log = Logger("MapUtils");
static const defaultSourceId = 'asset-map-markers';
static const defaultHeatMapLayerId = 'asset-heatmap-layer';
static const defaultHeatMapLayerProperties = HeatmapLayerProperties(
heatmapColor: [
Expressions.interpolate,
["linear"],
["heatmap-density"],
0.0,
"rgba(246,239,247,0.0)",
0.2,
"rgb(208,209,230)",
0.4,
"rgb(166,189,219)",
0.6,
"rgb(103,169,207)",
0.8,
"rgb(28,144,153)",
1.0,
"rgb(1,108,89)",
],
heatmapIntensity: [
Expressions.interpolate, ["linear"], //
[Expressions.zoom],
0, 0.5,
9, 2,
],
heatmapRadius: [
Expressions.interpolate, ["linear"], //
[Expressions.zoom],
0, 4,
4, 8,
9, 16,
],
);
static Map<String, dynamic> _addFeature(MapMarker marker) => {
'type': 'Feature',
'id': marker.assetRemoteId,
'geometry': {
'type': 'Point',
'coordinates': [marker.latLng.longitude, marker.latLng.latitude],
},
};
static Map<String, dynamic> generateGeoJsonForMarkers(
List<MapMarker> markers,
) =>
{
'type': 'FeatureCollection',
'features': markers.map(_addFeature).toList(),
};
static Future<(Position?, LocationPermission?)> checkPermAndGetLocation(
BuildContext context,
) async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
showDialog(
context: context,
builder: (context) => _LocationServiceDisabledDialog(),
);
return (null, LocationPermission.deniedForever);
}
LocationPermission permission = await Geolocator.checkPermission();
bool shouldRequestPermission = false;
if (permission == LocationPermission.denied) {
shouldRequestPermission = await showDialog(
context: context,
builder: (context) => _LocationPermissionDisabledDialog(),
);
if (shouldRequestPermission) {
permission = await Geolocator.requestPermission();
}
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
// Open app settings only if you did not request for permission before
if (permission == LocationPermission.deniedForever &&
!shouldRequestPermission) {
await Geolocator.openAppSettings();
}
return (null, LocationPermission.deniedForever);
}
Position currentUserLocation = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.medium,
timeLimit: const Duration(seconds: 5),
);
return (currentUserLocation, null);
} catch (error) {
_log.severe(
"Cannot get user's current location due to ${error.toString()}",
);
return (null, LocationPermission.unableToDetermine);
}
}
}
class _LocationServiceDisabledDialog extends ConfirmDialog {
_LocationServiceDisabledDialog()
: super(
title: 'map_location_service_disabled_title'.tr(),
content: 'map_location_service_disabled_content'.tr(),
cancel: 'map_location_dialog_cancel'.tr(),
ok: 'map_location_dialog_yes'.tr(),
onOk: () async {
await Geolocator.openLocationSettings();
},
);
}
class _LocationPermissionDisabledDialog extends ConfirmDialog {
_LocationPermissionDisabledDialog()
: super(
title: 'map_no_location_permission_title'.tr(),
content: 'map_no_location_permission_content'.tr(),
cancel: 'map_location_dialog_cancel'.tr(),
ok: 'map_location_dialog_yes'.tr(),
onOk: () {},
);
}

View File

@ -0,0 +1,185 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:immich_mobile/modules/map/utils/map_utils.dart';
import 'package:geolocator/geolocator.dart';
class MapLocationPickerPage extends HookConsumerWidget {
final LatLng initialLatLng;
const MapLocationPickerPage({
super.key,
this.initialLatLng = const LatLng(0, 0),
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedLatLng = useValueNotifier<LatLng>(initialLatLng);
final controller = useRef<MaplibreMapController?>(null);
final marker = useRef<Symbol?>(null);
Future<void> onStyleLoaded() async {
marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng);
}
Future<void> onMapClick(Point<num> point, LatLng centre) async {
selectedLatLng.value = centre;
controller.value?.animateCamera(CameraUpdate.newLatLng(centre));
if (marker.value != null) {
await controller.value
?.updateSymbol(marker.value!, SymbolOptions(geometry: centre));
}
}
void onClose([LatLng? selected]) {
context.popRoute(selected);
}
Future<void> getCurrentLocation() async {
var (currentLocation, locationPermission) = await MapUtils.checkPermAndGetLocation(context);
if (locationPermission == LocationPermission.denied ||
locationPermission == LocationPermission.deniedForever) {
return;
}
if (currentLocation == null) {
return;
}
var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude);
selectedLatLng.value = currentLatLng;
controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng));
}
return MapThemeOveride(
mapBuilder: (style) => Builder(
builder: (ctx) => Scaffold(
backgroundColor: ctx.themeData.cardColor,
appBar: _AppBar(onClose: onClose),
extendBodyBehindAppBar: true,
body: Column(
children: [
style.widgetWhen(
onData: (style) => Expanded(
child: Container(
clipBehavior: Clip.antiAliasWithSaveLayer,
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(40),
bottomRight: Radius.circular(40),
),
),
child: MaplibreMap(
initialCameraPosition:
CameraPosition(target: initialLatLng, zoom: 12),
styleString: style,
onMapCreated: (mapController) =>
controller.value = mapController,
onStyleLoadedCallback: onStyleLoaded,
onMapClick: onMapClick,
dragEnabled: false,
tiltGesturesEnabled: false,
myLocationEnabled: false,
attributionButtonMargins: const Point(20, 15),
),
),
),
),
_BottomBar(
selectedLatLng: selectedLatLng,
onUseLocation: () => onClose(selectedLatLng.value),
onGetCurrentLocation: getCurrentLocation,
),
],
),
),
),
);
}
}
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
final Function() onClose;
const _AppBar({required this.onClose});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top + 25),
child: Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: ElevatedButton(
onPressed: onClose,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
),
child: const Icon(Icons.arrow_back_ios_new_rounded),
),
),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(100);
}
class _BottomBar extends StatelessWidget {
final ValueNotifier<LatLng> selectedLatLng;
final Function() onUseLocation;
final Function() onGetCurrentLocation;
const _BottomBar({
required this.selectedLatLng,
required this.onUseLocation,
required this.onGetCurrentLocation,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 150,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.public, size: 18),
const SizedBox(width: 15),
ValueListenableBuilder(
valueListenable: selectedLatLng,
builder: (_, value, __) => Text(
"${value.latitude.toStringAsFixed(4)}, ${value.longitude.toStringAsFixed(4)}",
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: onUseLocation,
child: const Text("map_location_picker_page_use_location").tr(),
),
ElevatedButton(
onPressed: onGetCurrentLocation,
child: const Icon(Icons.my_location),
),
],
),
],
),
);
}
}

View File

@ -1,250 +1,225 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:flutter_map_heatmap/flutter_map_heatmap.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/modules/map/models/map_event.model.dart';
import 'package:immich_mobile/modules/map/models/map_marker.dart';
import 'package:immich_mobile/modules/map/providers/map_marker.provider.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/modules/map/ui/asset_marker_icon.dart';
import 'package:immich_mobile/modules/map/ui/location_dialog.dart';
import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart';
import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart';
import 'package:immich_mobile/modules/map/utils/map_utils.dart';
import 'package:immich_mobile/modules/map/widgets/map_app_bar.dart';
import 'package:immich_mobile/modules/map/widgets/map_asset_grid.dart';
import 'package:immich_mobile/modules/map/widgets/map_bottom_sheet.dart';
import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart';
import 'package:immich_mobile/modules/map/widgets/positioned_asset_marker_icon.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/extensions/flutter_map_extensions.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:latlong2/latlong.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class MapPage extends StatefulHookConsumerWidget {
class MapPage extends HookConsumerWidget {
const MapPage({super.key});
@override
MapPageState createState() => MapPageState();
}
Widget build(BuildContext context, WidgetRef ref) {
final mapController = useRef<MaplibreMapController?>(null);
final markers = useRef<List<MapMarker>>([]);
final markersInBounds = useRef<List<MapMarker>>([]);
final bottomSheetStreamController = useStreamController<MapEvent>();
final selectedMarker = useValueNotifier<_AssetMarkerMeta?>(null);
final assetsDebouncer = useDebouncer();
final isLoading = useProcessingOverlay();
final scrollController = useScrollController();
final markerDebouncer =
useDebouncer(interval: const Duration(milliseconds: 800));
final selectedAssets = useValueNotifier<Set<Asset>>({});
const mapZoomToAssetLevel = 12.0;
class MapPageState extends ConsumerState<MapPage> {
// Non-State variables
late final MapController mapController;
// Streams are used instead of callbacks to prevent unnecessary rebuilds on events
final StreamController mapPageEventSC =
StreamController<MapPageEventBase>.broadcast();
final StreamController bottomSheetEventSC =
StreamController<MapPageEventBase>.broadcast();
late final Stream bottomSheetEventStream;
// Making assets in bounds as a state variable will result in un-necessary rebuilds of the bottom sheet
// resulting in it getting reloaded each time a map move occurs
Set<AssetMarkerData> assetsInBounds = {};
// TODO: Migrate the handling to MapEventMove#id when flutter_map is upgraded
// https://github.com/fleaflet/flutter_map/issues/1542
// The below is used instead of MapEventMove#id to handle event from controller
// in onMapEvent() since MapEventMove#id is not populated properly in the
// current version of flutter_map(4.0.0) used
bool forceAssetUpdate = false;
bool isMapReady = false;
late final Debounce debounce;
@override
void initState() {
super.initState();
mapController = MapController();
bottomSheetEventStream = bottomSheetEventSC.stream;
// Map zoom events and move events are triggered often. Throttle the call to limit rebuilds
debounce = Debounce(
const Duration(milliseconds: 300),
);
}
@override
void dispose() {
debounce.dispose();
super.dispose();
}
void reloadAssetsInBound(
Set<AssetMarkerData>? assetMarkers, {
bool forceReload = false,
}) {
try {
final bounds = isMapReady ? mapController.bounds : null;
if (bounds != null) {
final oldAssetsInBounds = assetsInBounds.toSet();
assetsInBounds =
assetMarkers?.where((e) => bounds.contains(e.point)).toSet() ?? {};
final shouldReload = forceReload ||
assetsInBounds.difference(oldAssetsInBounds).isNotEmpty ||
assetsInBounds.length != oldAssetsInBounds.length;
if (shouldReload) {
mapPageEventSC.add(
MapPageAssetsInBoundUpdated(
assetsInBounds.map((e) => e.asset).toList(),
),
);
}
// updates the markersInBounds value with the map markers that are visible in the current
// map camera bounds
Future<void> updateAssetsInBounds() async {
// Guard map not created
if (mapController.value == null) {
return;
}
} finally {
// Consume all error
}
}
void openAssetInViewer(Asset asset) {
context.pushRoute(
GalleryViewerRoute(
initialIndex: 0,
loadAsset: (index) => asset,
totalAssets: 1,
heroOffset: 0,
),
final bounds = await mapController.value!.getVisibleRegion();
final inBounds = markers.value
.where(
(m) =>
bounds.contains(LatLng(m.latLng.latitude, m.latLng.longitude)),
)
.toList();
// Notify bottom sheet to update asset grid only when there are new assets
if (markersInBounds.value.length != inBounds.length) {
bottomSheetStreamController.add(
MapAssetsInBoundsUpdated(
inBounds.map((e) => e.assetRemoteId).toList(),
),
);
}
markersInBounds.value = inBounds;
}
// removes all sources and layers and re-adds them with the updated markers
Future<void> reloadLayers() async {
if (mapController.value != null) {
mapController.value!.reloadAllLayersForMarkers(markers.value);
}
}
Future<void> loadMarkers() async {
try {
isLoading.value = true;
markers.value = await ref.read(mapMarkersProvider.future);
assetsDebouncer.run(updateAssetsInBounds);
reloadLayers();
} finally {
isLoading.value = false;
}
}
useEffect(
() {
loadMarkers();
return null;
},
[],
);
}
@override
Widget build(BuildContext context) {
final log = Logger("MapService");
final isDarkTheme =
ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
final ValueNotifier<Set<AssetMarkerData>> mapMarkerData =
useState(<AssetMarkerData>{});
final ValueNotifier<AssetMarkerData?> closestAssetMarker = useState(null);
final selectionEnabledHook = useState(false);
final selectedAssets = useState(<Asset>{});
final showLoadingIndicator = useState(false);
final refetchMarkers = useState(true);
final isLoading =
ref.watch(mapStateNotifier.select((state) => state.isLoading));
final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom;
final zoomLevel = math.min(maxZoom, 14.0);
if (refetchMarkers.value) {
mapMarkerData.value = ref.watch(mapMarkersProvider).when(
skipLoadingOnRefresh: false,
error: (error, stackTrace) {
log.warning(
"Cannot get map markers ${error.toString()}",
error,
stackTrace,
);
showLoadingIndicator.value = false;
return {};
},
loading: () {
showLoadingIndicator.value = true;
return {};
},
data: (data) {
showLoadingIndicator.value = false;
refetchMarkers.value = false;
closestAssetMarker.value = null;
debounce(
() => reloadAssetsInBound(
mapMarkerData.value,
forceReload: true,
),
);
return data;
},
);
}
ref.listen(mapStateNotifier, (previous, next) {
bool shouldRefetch =
previous?.showFavoriteOnly != next.showFavoriteOnly ||
previous?.relativeTime != next.relativeTime ||
previous?.includeArchived != next.includeArchived;
if (shouldRefetch) {
refetchMarkers.value = shouldRefetch;
ref.invalidate(mapMarkersProvider);
// Refetch markers when map state is changed
ref.listen(mapStateNotifierProvider, (_, current) {
if (current.shouldRefetchMarkers) {
markerDebouncer.run(() {
ref.invalidate(mapMarkersProvider);
// Reset marker
selectedMarker.value = null;
loadMarkers();
ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(false);
});
}
});
void onZoomToAssetEvent(Asset? assetInBottomSheet) {
if (assetInBottomSheet != null) {
final mapMarker = mapMarkerData.value
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
if (mapMarker != null) {
LatLng? newCenter = mapController.centerBoundsWithPadding(
mapMarker.point,
const Offset(0, -120),
zoomLevel: zoomLevel,
);
if (newCenter != null) {
forceAssetUpdate = true;
mapController.move(newCenter, zoomLevel);
}
// updates the selected markers position based on the current map camera
Future<void> updateAssetMarkerPosition(
MapMarker marker, {
bool shouldAnimate = true,
}) async {
final assetPoint =
await mapController.value!.toScreenLocation(marker.latLng);
selectedMarker.value = _AssetMarkerMeta(
point: assetPoint,
marker: marker,
shouldAnimate: shouldAnimate,
);
(assetPoint, marker, shouldAnimate);
}
// finds the nearest asset marker from the tap point and store it as the selectedMarker
Future<void> onMarkerClicked(Point<double> point, LatLng coords) async {
// Guard map not created
if (mapController.value == null) {
return;
}
final latlngBound =
await mapController.value!.getBoundsFromPoint(point, 50);
final marker = markersInBounds.value.firstWhereOrNull(
(m) =>
latlngBound.contains(LatLng(m.latLng.latitude, m.latLng.longitude)),
);
if (marker != null) {
updateAssetMarkerPosition(marker);
} else {
// If no asset was previously selected and no new asset is available, close the bottom sheet
if (selectedMarker.value == null) {
bottomSheetStreamController.add(MapCloseBottomSheet());
}
selectedMarker.value = null;
}
}
void onMapCreated(MaplibreMapController controller) async {
mapController.value = controller;
controller.addListener(() {
if (controller.isCameraMoving && selectedMarker.value != null) {
updateAssetMarkerPosition(
selectedMarker.value!.marker,
shouldAnimate: false,
);
}
});
}
Future<void> onMarkerTapped() async {
final assetId = selectedMarker.value?.marker.assetRemoteId;
if (assetId == null) {
return;
}
final asset = await ref.read(dbProvider).assets.getByRemoteId(assetId);
if (asset == null) {
return;
}
context.pushRoute(
GalleryViewerRoute(
initialIndex: 0,
loadAsset: (index) => asset,
totalAssets: 1,
heroOffset: 0,
),
);
}
/// BOTTOM SHEET CALLBACKS
Future<void> onMapMoved() async {
assetsDebouncer.run(updateAssetsInBounds);
}
void onBottomSheetScrolled(String assetRemoteId) {
final assetMarker = markersInBounds.value
.firstWhereOrNull((m) => m.assetRemoteId == assetRemoteId);
if (assetMarker != null) {
updateAssetMarkerPosition(assetMarker);
}
}
void onZoomToAsset(String assetRemoteId) {
final assetMarker = markersInBounds.value
.firstWhereOrNull((m) => m.assetRemoteId == assetRemoteId);
if (mapController.value != null && assetMarker != null) {
// Offset the latitude a little to show the marker just above the viewports center
final offset = context.isMobile ? 0.02 : 0;
final latlng = LatLng(
assetMarker.latLng.latitude - offset,
assetMarker.latLng.longitude,
);
mapController.value!.animateCamera(
CameraUpdate.newLatLngZoom(latlng, mapZoomToAssetLevel),
duration: const Duration(milliseconds: 800),
);
}
}
void onZoomToLocation() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
showDialog(
context: context,
builder: (context) => Theme(
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
child: LocationServiceDisabledDialog(),
),
);
return;
}
LocationPermission permission = await Geolocator.checkPermission();
bool shouldRequestPermission = false;
if (permission == LocationPermission.denied) {
shouldRequestPermission = await showDialog(
context: context,
builder: (context) => Theme(
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
child: LocationPermissionDisabledDialog(),
),
);
if (shouldRequestPermission) {
permission = await Geolocator.requestPermission();
}
}
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
// Open app settings only if you did not request for permission before
if (permission == LocationPermission.deniedForever &&
!shouldRequestPermission) {
await Geolocator.openAppSettings();
}
return;
}
Position currentUserLocation = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.medium,
timeLimit: const Duration(seconds: 5),
);
forceAssetUpdate = true;
mapController.move(
LatLng(currentUserLocation.latitude, currentUserLocation.longitude),
zoomLevel,
);
} catch (error) {
log.severe(
"Cannot get user's current location due to ${error.toString()}",
);
if (context.mounted) {
final location = await MapUtils.checkPermAndGetLocation(context);
if (location.$2 != null) {
if (location.$2 == LocationPermission.unableToDetermine &&
context.mounted) {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
@ -252,253 +227,180 @@ class MapPageState extends ConsumerState<MapPage> {
msg: "map_cannot_get_user_location".tr(),
);
}
return;
}
}
void handleBottomSheetEvents(dynamic event) {
if (event is MapPageBottomSheetScrolled) {
final assetInBottomSheet = event.asset;
if (assetInBottomSheet != null) {
final mapMarker = mapMarkerData.value
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
closestAssetMarker.value = mapMarker;
if (mapMarker != null && mapController.zoom >= 5) {
LatLng? newCenter = mapController.centerBoundsWithPadding(
mapMarker.point,
const Offset(0, -120),
);
if (newCenter != null) {
mapController.move(
newCenter,
mapController.zoom,
);
}
}
}
} else if (event is MapPageZoomToAsset) {
onZoomToAssetEvent(event.asset);
} else if (event is MapPageZoomToLocation) {
onZoomToLocation();
}
}
useEffect(
() {
final bottomSheetEventSubscription =
bottomSheetEventStream.listen(handleBottomSheetEvents);
return bottomSheetEventSubscription.cancel;
},
[bottomSheetEventStream],
);
void handleMapTapEvent(LatLng tapPosition) {
const d = Distance();
final assetsInBoundsList = assetsInBounds.toList();
assetsInBoundsList.sort(
(a, b) => d
.distance(a.point, tapPosition)
.compareTo(d.distance(b.point, tapPosition)),
);
// First asset less than the threshold from the tap point
final nearestAsset = assetsInBoundsList.firstWhereOrNull(
(element) =>
d.distance(element.point, tapPosition) <
mapController.getTapThresholdForZoomLevel(),
);
// Reset marker if no assets are near the tap point
if (nearestAsset == null && closestAssetMarker.value != null) {
selectionEnabledHook.value = false;
mapPageEventSC.add(
const MapPageOnTapEvent(),
if (mapController.value != null && location.$1 != null) {
mapController.value!.animateCamera(
CameraUpdate.newLatLngZoom(
LatLng(location.$1!.latitude, location.$1!.longitude),
mapZoomToAssetLevel,
),
duration: const Duration(milliseconds: 800),
);
}
closestAssetMarker.value = nearestAsset;
}
void onMapEvent(MapEvent mapEvent) {
if (mapEvent is MapEventMove || mapEvent is MapEventDoubleTapZoom) {
if (forceAssetUpdate ||
mapEvent.source != MapEventSource.mapController) {
debounce(() {
if (selectionEnabledHook.value) {
selectionEnabledHook.value = false;
}
reloadAssetsInBound(
mapMarkerData.value,
forceReload: forceAssetUpdate,
);
forceAssetUpdate = false;
});
}
} else if (mapEvent is MapEventTap) {
handleMapTapEvent(mapEvent.tapPosition);
}
void onAssetsSelected(bool selected, Set<Asset> selection) {
selectedAssets.value = selected ? selection : {};
}
void onShareAsset() {
handleShareAssets(ref, context, selectedAssets.value.toList());
selectionEnabledHook.value = false;
}
return MapThemeOveride(
mapBuilder: (style) => context.isMobile
// Single-column
? Scaffold(
extendBodyBehindAppBar: true,
appBar: MapAppBar(selectedAssets: selectedAssets),
body: Stack(
children: [
_MapWithMarker(
style: style,
selectedMarker: selectedMarker,
onMapCreated: onMapCreated,
onMapMoved: onMapMoved,
onMapClicked: onMarkerClicked,
onStyleLoaded: reloadLayers,
onMarkerTapped: onMarkerTapped,
),
// Should be a part of the body and not scaffold::bottomsheet for the
// location button to be hit testable
MapBottomSheet(
mapEventStream: bottomSheetStreamController.stream,
onGridAssetChanged: onBottomSheetScrolled,
onZoomToAsset: onZoomToAsset,
onAssetsSelected: onAssetsSelected,
onZoomToLocation: onZoomToLocation,
selectedAssets: selectedAssets,
),
],
),
)
// Two-pane
: Row(
children: [
Expanded(
child: Scaffold(
extendBodyBehindAppBar: true,
appBar: MapAppBar(selectedAssets: selectedAssets),
body: Stack(
children: [
_MapWithMarker(
style: style,
selectedMarker: selectedMarker,
onMapCreated: onMapCreated,
onMapMoved: onMapMoved,
onMapClicked: onMarkerClicked,
onStyleLoaded: reloadLayers,
onMarkerTapped: onMarkerTapped,
),
Positioned(
right: 0,
bottom: 30,
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
),
child: const Icon(Icons.my_location),
),
),
],
),
),
),
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) => MapAssetGrid(
controller: scrollController,
mapEventStream: bottomSheetStreamController.stream,
onGridAssetChanged: onBottomSheetScrolled,
onZoomToAsset: onZoomToAsset,
onAssetsSelected: onAssetsSelected,
selectedAssets: selectedAssets,
),
),
),
],
),
);
}
}
void onFavoriteAsset() async {
showLoadingIndicator.value = true;
try {
await handleFavoriteAssets(ref, context, selectedAssets.value.toList());
} finally {
showLoadingIndicator.value = false;
selectionEnabledHook.value = false;
refetchMarkers.value = true;
}
}
class _AssetMarkerMeta {
final Point<num> point;
final MapMarker marker;
final bool shouldAnimate;
void onArchiveAsset() async {
showLoadingIndicator.value = true;
try {
await handleArchiveAssets(ref, context, selectedAssets.value.toList());
} finally {
showLoadingIndicator.value = false;
selectionEnabledHook.value = false;
refetchMarkers.value = true;
}
}
const _AssetMarkerMeta({
required this.point,
required this.marker,
required this.shouldAnimate,
});
void selectionListener(bool isMultiSelect, Set<Asset> selection) {
selectionEnabledHook.value = isMultiSelect;
selectedAssets.value = selection;
}
@override
String toString() =>
'_AssetMarkerMeta(point: $point, marker: $marker, shouldAnimate: $shouldAnimate)';
}
final markerLayer = MarkerLayer(
markers: [
if (closestAssetMarker.value != null)
AssetMarker(
remoteId: closestAssetMarker.value!.asset.remoteId!,
anchorPos: AnchorPos.align(AnchorAlign.top),
point: closestAssetMarker.value!.point,
width: 100,
height: 100,
builder: (ctx) => GestureDetector(
onTap: () => openAssetInViewer(closestAssetMarker.value!.asset),
child: AssetMarkerIcon(
key: Key(closestAssetMarker.value!.asset.remoteId!),
isDarkTheme: isDarkTheme,
id: closestAssetMarker.value!.asset.remoteId!,
class _MapWithMarker extends StatelessWidget {
final AsyncValue<String> style;
final MapCreatedCallback onMapCreated;
final OnCameraIdleCallback onMapMoved;
final OnMapClickCallback onMapClicked;
final OnStyleLoadedCallback onStyleLoaded;
final Function()? onMarkerTapped;
final ValueNotifier<_AssetMarkerMeta?> selectedMarker;
const _MapWithMarker({
required this.style,
required this.onMapCreated,
required this.onMapMoved,
required this.onMapClicked,
required this.onStyleLoaded,
required this.selectedMarker,
this.onMarkerTapped,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (ctx, constraints) => SizedBox(
height: constraints.maxHeight,
width: constraints.maxWidth,
child: Stack(
children: [
style.widgetWhen(
onData: (style) => MaplibreMap(
initialCameraPosition:
const CameraPosition(target: LatLng(0, 0)),
styleString: style,
// This is needed to update the selectedMarker's position on map camera updates
// The changes are notified through the mapController ValueListener which is added in [onMapCreated]
trackCameraPosition: true,
onMapCreated: onMapCreated,
onCameraIdle: onMapMoved,
onMapClick: onMapClicked,
onStyleLoadedCallback: onStyleLoaded,
tiltGesturesEnabled: false,
dragEnabled: false,
myLocationEnabled: false,
attributionButtonPosition: AttributionButtonPosition.TopRight,
),
),
),
],
);
final heatMapLayer = mapMarkerData.value.isNotEmpty
? HeatMapLayer(
heatMapDataSource: InMemoryHeatMapDataSource(
data: mapMarkerData.value
.map(
(e) => WeightedLatLng(
LatLng(e.point.latitude, e.point.longitude),
1,
),
)
.toList(),
ValueListenableBuilder(
valueListenable: selectedMarker,
builder: (ctx, value, _) => value != null
? PositionedAssetMarkerIcon(
point: value.point,
assetRemoteId: value.marker.assetRemoteId,
durationInMilliseconds: value.shouldAnimate ? 100 : 0,
onTap: onMarkerTapped,
)
: const SizedBox.shrink(),
),
heatMapOptions: HeatMapOptions(
radius: 60,
layerOpacity: 0.5,
gradient: {
0.20: Colors.deepPurple,
0.40: Colors.blue,
0.60: Colors.green,
0.95: Colors.yellow,
1.0: Colors.deepOrange,
},
),
)
: const SizedBox.shrink();
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarColor:
(isDarkTheme ? Colors.black : Colors.white).withOpacity(0.5),
statusBarIconBrightness:
isDarkTheme ? Brightness.light : Brightness.dark,
systemNavigationBarColor:
isDarkTheme ? Colors.grey[900] : Colors.grey[100],
systemNavigationBarIconBrightness:
isDarkTheme ? Brightness.light : Brightness.dark,
systemNavigationBarDividerColor: Colors.transparent,
),
child: Theme(
// Override app theme based on map theme
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
child: Scaffold(
appBar: MapAppBar(
isDarkTheme: isDarkTheme,
selectionEnabled: selectionEnabledHook,
selectedAssetsLength: selectedAssets.value.length,
onShare: onShareAsset,
onArchive: onArchiveAsset,
onFavorite: onFavoriteAsset,
),
extendBodyBehindAppBar: true,
body: Stack(
children: [
if (!isLoading)
FlutterMap(
mapController: mapController,
options: MapOptions(
maxBounds:
LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
interactiveFlags: InteractiveFlag.doubleTapZoom |
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchMove |
InteractiveFlag.pinchZoom,
center: LatLng(20, 20),
zoom: 2,
minZoom: 1,
maxZoom: maxZoom,
onMapReady: () {
isMapReady = true;
mapController.mapEventStream.listen(onMapEvent);
},
),
children: [
ref.read(mapStateNotifier.notifier).getTileLayer(),
heatMapLayer,
markerLayer,
],
),
if (!isLoading)
MapPageBottomSheet(
mapPageEventStream: mapPageEventSC.stream,
bottomSheetEventSC: bottomSheetEventSC,
selectionEnabled: selectionEnabledHook.value,
selectionlistener: selectionListener,
isDarkTheme: isDarkTheme,
),
if (showLoadingIndicator.value || isLoading)
Positioned(
top: context.height * 0.35,
left: context.width * 0.425,
child: const ImmichLoadingIndicator(),
),
],
),
],
),
),
);
}
}
class AssetMarker extends Marker {
String remoteId;
AssetMarker({
super.key,
required this.remoteId,
super.anchorPos,
required super.point,
super.width = 100.0,
super.height = 100.0,
required super.builder,
});
}

View File

@ -0,0 +1,159 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/modules/map/widgets/map_settings_sheet.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class MapAppBar extends HookWidget implements PreferredSizeWidget {
final ValueNotifier<Set<Asset>> selectedAssets;
const MapAppBar({super.key, required this.selectedAssets});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top + 25),
child: ValueListenableBuilder(
valueListenable: selectedAssets,
builder: (ctx, value, child) => value.isNotEmpty
? _SelectionRow(selectedAssets: selectedAssets)
: _NonSelectionRow(),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(100);
}
class _NonSelectionRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
void onSettingsPressed() {
showModalBottomSheet(
elevation: 0.0,
showDragHandle: true,
isScrollControlled: true,
context: context,
builder: (_) => const MapSettingsSheet(),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton(
onPressed: () => context.popRoute(),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
),
child: const Icon(Icons.arrow_back_ios_new_rounded),
),
ElevatedButton(
onPressed: onSettingsPressed,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
),
child: const Icon(Icons.more_vert_rounded),
),
],
);
}
}
class _SelectionRow extends HookConsumerWidget {
final ValueNotifier<Set<Asset>> selectedAssets;
const _SelectionRow({required this.selectedAssets});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isProcessing = useProcessingOverlay();
Future<void> handleProcessing(
FutureOr<void> Function() action, [
bool reloadMarkers = false,
]) async {
isProcessing.value = true;
await action();
// Reset state
selectedAssets.value = {};
isProcessing.value = false;
if (reloadMarkers) {
ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(true);
}
}
return Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 20),
child: ElevatedButton.icon(
onPressed: () => selectedAssets.value = {},
icon: const Icon(Icons.close_rounded),
label: Text(
'${selectedAssets.value.length}',
style: context.textTheme.titleMedium?.copyWith(
color: context.colorScheme.onPrimary,
),
),
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton(
onPressed: () => handleProcessing(
() => handleShareAssets(
ref,
context,
selectedAssets.value.toList(),
),
),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
),
child: const Icon(Icons.ios_share_rounded),
),
ElevatedButton(
onPressed: () => handleProcessing(
() => handleFavoriteAssets(
ref,
context,
selectedAssets.value.toList(),
),
),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
),
child: const Icon(Icons.favorite),
),
ElevatedButton(
onPressed: () => handleProcessing(
() => handleArchiveAssets(
ref,
context,
selectedAssets.value.toList(),
),
true,
),
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
),
child: const Icon(Icons.archive),
),
],
),
),
],
);
}
}

View File

@ -0,0 +1,273 @@
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/map/models/map_event.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:immich_mobile/utils/throttle.dart';
import 'package:logging/logging.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class MapAssetGrid extends HookConsumerWidget {
final Stream<MapEvent> mapEventStream;
final Function(String)? onGridAssetChanged;
final Function(String)? onZoomToAsset;
final Function(bool, Set<Asset>)? onAssetsSelected;
final ValueNotifier<Set<Asset>> selectedAssets;
final ScrollController controller;
const MapAssetGrid({
required this.mapEventStream,
this.onGridAssetChanged,
this.onZoomToAsset,
this.onAssetsSelected,
required this.selectedAssets,
required this.controller,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final log = Logger("MapAssetGrid");
final assetsInBounds = useState<List<Asset>>([]);
final cachedRenderList = useRef<RenderList?>(null);
final lastRenderElementIndex = useRef<int?>(null);
final assetInSheet = useValueNotifier<String?>(null);
final gridScrollThrottler =
useThrottler(interval: const Duration(milliseconds: 300));
void handleMapEvents(MapEvent event) async {
if (event is MapAssetsInBoundsUpdated) {
assetsInBounds.value = await ref
.read(dbProvider)
.assets
.getAllByRemoteId(event.assetRemoteIds);
return;
}
}
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
// Hard-restrict to 4 assets / row in portrait mode
const assetsPerRow = 4;
void handleVisibleItems(Iterable<ItemPosition> positions) {
final orderedPos = positions.sortedByField((p) => p.index);
// Index of row where the items are mostly visible
const partialOffset = 0.20;
final item = orderedPos
.firstWhereOrNull((p) => p.itemTrailingEdge > partialOffset);
// Guard no elements, reset state
// Also fail fast when the sheet is just opened and the user is yet to scroll (i.e leading = 0)
if (item == null || item.itemLeadingEdge == 0) {
lastRenderElementIndex.value = null;
return;
}
final renderElement =
cachedRenderList.value?.elements.elementAtOrNull(item.index);
// Guard no render list or render element
if (renderElement == null) {
return;
}
// Reset index
lastRenderElementIndex.value == item.index;
// <RenderElement:offset:0>
// | 1 | 2 | 3 | 4 | 5 | 6 |
// <RenderElement:offset:6>
// | 7 | 8 | 9 |
// <RenderElement:offset:9>
// | 10 |
// Skip through the assets from the previous row
final rowOffset = renderElement.offset;
// Column offset = (total trailingEdge - trailingEdge crossed) / offset for each asset
final totalOffset = item.itemTrailingEdge - item.itemLeadingEdge;
final edgeOffset = (totalOffset - partialOffset) /
// Round the total count to the next multiple of [assetsPerRow]
((renderElement.totalCount / assetsPerRow) * assetsPerRow).floor();
// trailing should never be above the totalOffset
final columnOffset =
(totalOffset - math.min(item.itemTrailingEdge, totalOffset)) ~/
edgeOffset;
final assetOffset = rowOffset + columnOffset;
final selectedAsset = cachedRenderList.value?.allAssets
?.elementAtOrNull(assetOffset)
?.remoteId;
if (selectedAsset != null) {
onGridAssetChanged?.call(selectedAsset);
assetInSheet.value = selectedAsset;
}
}
return Card(
margin: EdgeInsets.zero,
child: Stack(
children: [
/// The Align and FractionallySizedBox are to prevent the Asset Grid from going behind the
/// _MapSheetDragRegion and thereby displaying content behind the top right and top left curves
Align(
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
// Place it just below the drag handle
heightFactor: 0.80,
child: assetsInBounds.value.isNotEmpty
? ref.watch(renderListProvider(assetsInBounds.value)).when(
data: (renderList) {
// Cache render list here to use it back during visibleItemsListener
cachedRenderList.value = renderList;
return ValueListenableBuilder(
valueListenable: selectedAssets,
builder: (_, value, __) => ImmichAssetGrid(
shrinkWrap: true,
renderList: renderList,
showDragScroll: false,
assetsPerRow: assetsPerRow,
showMultiSelectIndicator: false,
selectionActive: value.isNotEmpty,
listener: onAssetsSelected,
visibleItemsListener: (pos) => gridScrollThrottler
.run(() => handleVisibleItems(pos)),
),
);
},
error: (error, stackTrace) {
log.warning(
"Cannot get assets in the current map bounds $error",
error,
stackTrace,
);
return const SizedBox.shrink();
},
loading: () => const SizedBox.shrink(),
)
: _MapNoAssetsInSheet(),
),
),
_MapSheetDragRegion(
controller: controller,
assetsInBoundCount: assetsInBounds.value.length,
assetInSheet: assetInSheet,
onZoomToAsset: onZoomToAsset,
),
],
),
);
}
}
class _MapNoAssetsInSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
const image = Image(
height: 150,
width: 150,
image: AssetImage('assets/lighthouse.png'),
);
return Center(
child: ListView(
shrinkWrap: true,
children: [
context.isDarkTheme
? const InvertionFilter(
child: SaturationFilter(
saturation: -1,
child: BrightnessFilter(
brightness: -5,
child: image,
),
),
)
: image,
const SizedBox(height: 20),
Center(
child: Text(
"map_zoom_to_see_photos".tr(),
style: context.textTheme.displayLarge?.copyWith(fontSize: 18),
),
),
],
),
);
}
}
class _MapSheetDragRegion extends StatelessWidget {
final ScrollController controller;
final int assetsInBoundCount;
final ValueNotifier<String?> assetInSheet;
final Function(String)? onZoomToAsset;
const _MapSheetDragRegion({
required this.controller,
required this.assetsInBoundCount,
required this.assetInSheet,
this.onZoomToAsset,
});
@override
Widget build(BuildContext context) {
final assetsInBoundsText = assetsInBoundCount > 0
? "map_assets_in_bounds".tr(args: [assetsInBoundCount.toString()])
: "map_no_assets_in_bounds".tr();
return SingleChildScrollView(
controller: controller,
physics: const ClampingScrollPhysics(),
child: Card(
margin: EdgeInsets.zero,
shape: context.isMobile ? null : const BeveledRectangleBorder(),
elevation: 0.0,
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 15),
const CustomDraggingHandle(),
const SizedBox(height: 15),
Text(assetsInBoundsText, style: context.textTheme.bodyLarge),
const Divider(height: 35),
],
),
ValueListenableBuilder(
valueListenable: assetInSheet,
builder: (_, value, __) => Visibility(
visible: value != null,
child: Positioned(
right: 15,
top: 15,
child: IconButton(
icon: Icon(
Icons.map_outlined,
color: context.textTheme.displayLarge?.color,
),
iconSize: 20,
tooltip: 'Zoom to bounds',
onPressed: () => onZoomToAsset?.call(value!),
),
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/map/models/map_event.model.dart';
import 'package:immich_mobile/modules/map/widgets/map_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
class MapBottomSheet extends HookConsumerWidget {
final Stream<MapEvent> mapEventStream;
final Function(String)? onGridAssetChanged;
final Function(String)? onZoomToAsset;
final Function()? onZoomToLocation;
final Function(bool, Set<Asset>)? onAssetsSelected;
final ValueNotifier<Set<Asset>> selectedAssets;
const MapBottomSheet({
required this.mapEventStream,
this.onGridAssetChanged,
this.onZoomToAsset,
this.onAssetsSelected,
this.onZoomToLocation,
required this.selectedAssets,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
const sheetMinExtent = 0.1;
final sheetController = useDraggableScrollController();
final bottomSheetOffset = useValueNotifier(sheetMinExtent);
final isBottomSheetOpened = useRef(false);
void handleMapEvents(MapEvent event) async {
if (event is MapCloseBottomSheet) {
sheetController.animateTo(
0.1,
duration: const Duration(milliseconds: 200),
curve: Curves.linearToEaseOut,
);
}
}
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
bool onScrollNotification(DraggableScrollableNotification notification) {
isBottomSheetOpened.value =
notification.extent > (notification.maxExtent * 0.9);
bottomSheetOffset.value = notification.extent;
// do not bubble
return true;
}
return Stack(
children: [
NotificationListener<DraggableScrollableNotification>(
onNotification: onScrollNotification,
child: DraggableScrollableSheet(
controller: sheetController,
minChildSize: sheetMinExtent,
maxChildSize: 0.5,
initialChildSize: sheetMinExtent,
snap: true,
shouldCloseOnMinExtent: false,
builder: (ctx, scrollController) => MapAssetGrid(
controller: scrollController,
mapEventStream: mapEventStream,
selectedAssets: selectedAssets,
onAssetsSelected: onAssetsSelected,
// Do not bother with the event if the bottom sheet is not user scrolled
onGridAssetChanged: (assetId) => isBottomSheetOpened.value
? onGridAssetChanged?.call(assetId)
: null,
onZoomToAsset: onZoomToAsset,
),
),
),
ValueListenableBuilder(
valueListenable: bottomSheetOffset,
builder: (ctx, value, child) => Positioned(
right: 0,
bottom: context.height * (value + 0.02),
child: child!,
),
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
),
child: const Icon(Icons.my_location),
),
),
],
);
}
}

View File

@ -0,0 +1,31 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class MapSettingsListTile extends StatelessWidget {
final String title;
final bool selected;
final Function(bool) onChanged;
const MapSettingsListTile({
super.key,
required this.title,
required this.selected,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return SwitchListTile.adaptive(
activeColor: context.primaryColor,
title: Text(
title,
style:
context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
).tr(),
value: selected,
onChanged: onChanged,
);
}
}

View File

@ -0,0 +1,92 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class MapTimeDropDown extends StatelessWidget {
final int relativeTime;
final Function(int) onTimeChange;
const MapTimeDropDown({
super.key,
required this.relativeTime,
required this.onTimeChange,
});
@override
Widget build(BuildContext context) {
final now = DateTime.now();
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
"map_settings_only_relative_range".tr(),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
LayoutBuilder(
builder: (_, constraints) => DropdownMenu(
width: constraints.maxWidth * 0.9,
enableSearch: false,
enableFilter: false,
initialSelection: relativeTime,
onSelected: (value) => onTimeChange(value!),
dropdownMenuEntries: [
DropdownMenuEntry(
value: 0,
label: "map_settings_date_range_option_all".tr(),
),
DropdownMenuEntry(
value: 1,
label: "map_settings_date_range_option_day".tr(),
),
DropdownMenuEntry(
value: 7,
label: "map_settings_date_range_option_days".tr(
args: ["7"],
),
),
DropdownMenuEntry(
value: 30,
label: "map_settings_date_range_option_days".tr(
args: ["30"],
),
),
DropdownMenuEntry(
value: now
.difference(
DateTime(
now.year - 1,
now.month,
now.day,
now.hour,
now.minute,
now.second,
),
)
.inDays,
label: "map_settings_date_range_option_year".tr(),
),
DropdownMenuEntry(
value: now
.difference(
DateTime(
now.year - 3,
now.month,
now.day,
now.hour,
now.minute,
now.second,
),
)
.inDays,
label: "map_settings_date_range_option_years".tr(args: ["3"]),
),
],
),
),
],
);
}
}

View File

@ -0,0 +1,109 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class MapThemePicker extends StatelessWidget {
final ThemeMode themeMode;
final Function(ThemeMode) onThemeChange;
const MapThemePicker({
super.key,
required this.themeMode,
required this.onThemeChange,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Center(
child: Text(
"map_settings_theme_settings",
style: context.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BorderedMapThumbnail(
name: "Light",
mode: ThemeMode.light,
shouldHighlight: themeMode == ThemeMode.light,
onThemeChange: onThemeChange,
),
_BorderedMapThumbnail(
name: "Dark",
mode: ThemeMode.dark,
shouldHighlight: themeMode == ThemeMode.dark,
onThemeChange: onThemeChange,
),
_BorderedMapThumbnail(
name: "System",
mode: ThemeMode.system,
shouldHighlight: themeMode == ThemeMode.system,
onThemeChange: onThemeChange,
),
],
),
],
);
}
}
class _BorderedMapThumbnail extends StatelessWidget {
final ThemeMode mode;
final String name;
final bool shouldHighlight;
final Function(ThemeMode) onThemeChange;
const _BorderedMapThumbnail({
required this.mode,
required this.name,
required this.shouldHighlight,
required this.onThemeChange,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
decoration: BoxDecoration(
border: Border.fromBorderSide(
BorderSide(
width: 4,
color: shouldHighlight
? context.colorScheme.onSurface
: Colors.transparent,
),
),
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: MapThumbnail(
zoom: 2,
centre: const LatLng(47, 5),
onTap: (_, __) => onThemeChange(mode),
themeMode: mode,
showAttribution: false,
),
),
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
name,
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: shouldHighlight ? FontWeight.bold : null,
),
),
),
],
);
}
}

View File

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/modules/map/widgets/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/modules/map/widgets/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/modules/map/widgets/map_settings/map_theme_picker.dart';
class MapSettingsSheet extends HookConsumerWidget {
const MapSettingsSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapState = ref.watch(mapStateNotifierProvider);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
child: Card(
elevation: 0.0,
shadowColor: Colors.transparent,
margin: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
MapThemePicker(
themeMode: mapState.themeMode,
onThemeChange: (mode) => ref
.read(mapStateNotifierProvider.notifier)
.switchTheme(mode),
),
const Divider(height: 30, thickness: 2),
MapSettingsListTile(
title: "map_settings_only_show_favorites",
selected: mapState.showFavoriteOnly,
onChanged: (favoriteOnly) => ref
.read(mapStateNotifierProvider.notifier)
.switchFavoriteOnly(favoriteOnly),
),
MapSettingsListTile(
title: "map_settings_include_show_archived",
selected: mapState.includeArchived,
onChanged: (includeArchive) => ref
.read(mapStateNotifierProvider.notifier)
.switchIncludeArchived(includeArchive),
),
MapTimeDropDown(
relativeTime: mapState.relativeTime,
onTimeChange: (time) => ref
.read(mapStateNotifierProvider.notifier)
.setRelativeTime(time),
),
const SizedBox(height: 20),
],
),
),
),
);
}
}

View File

@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
/// Overrides the theme below the widget tree to use the theme data based on the
/// map settings instead of the one from the app settings
class MapThemeOveride extends StatefulHookConsumerWidget {
final ThemeMode? themeMode;
final Widget Function(AsyncValue<String> style) mapBuilder;
const MapThemeOveride({required this.mapBuilder, this.themeMode, super.key});
@override
ConsumerState createState() => _MapThemeOverideState();
}
class _MapThemeOverideState extends ConsumerState<MapThemeOveride>
with WidgetsBindingObserver {
late ThemeMode _theme;
bool _isDarkTheme = false;
bool get _isSystemDark =>
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
Brightness.dark;
bool checkDarkTheme() {
return _theme == ThemeMode.dark ||
_theme == ThemeMode.system && _isSystemDark;
}
@override
void initState() {
super.initState();
_theme = widget.themeMode ??
ref.read(mapStateNotifierProvider.select((v) => v.themeMode));
setState(() {
_isDarkTheme = checkDarkTheme();
});
if (_theme == ThemeMode.system) {
WidgetsBinding.instance.addObserver(this);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_theme != ThemeMode.system) {
WidgetsBinding.instance.removeObserver(this);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangePlatformBrightness() {
super.didChangePlatformBrightness();
if (_theme == ThemeMode.system) {
setState(() => _isDarkTheme = _isSystemDark);
}
}
@override
Widget build(BuildContext context) {
_theme = widget.themeMode ??
ref.watch(mapStateNotifierProvider.select((v) => v.themeMode));
useValueChanged<ThemeMode, void>(_theme, (_, __) {
if (_theme == ThemeMode.system) {
WidgetsBinding.instance.addObserver(this);
} else {
WidgetsBinding.instance.removeObserver(this);
}
setState(() {
_isDarkTheme = checkDarkTheme();
});
});
return Theme(
data: _isDarkTheme ? immichDarkTheme : immichLightTheme,
child: widget.mapBuilder.call(
ref.watch(
mapStateNotifierProvider.select(
(v) => _isDarkTheme ? v.darkStyleFetched : v.lightStyleFetched,
),
),
),
);
}
}

View File

@ -0,0 +1,110 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart';
import 'package:immich_mobile/modules/map/widgets/positioned_asset_marker_icon.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
/// A non-interactive thumbnail of a map in the given coordinates with optional markers
///
/// User can provide either a [assetMarkerRemoteId] to display the asset's thumbnail or set
/// [showMarkerPin] to true which would display a marker pin instead. If both are provided,
/// [assetMarkerRemoteId] will take precedence
class MapThumbnail extends HookConsumerWidget {
final Function(Point<double>, LatLng)? onTap;
final LatLng centre;
final String? assetMarkerRemoteId;
final bool showMarkerPin;
final double zoom;
final double height;
final double width;
final ThemeMode? themeMode;
final bool showAttribution;
const MapThumbnail({
super.key,
required this.centre,
this.height = 100,
this.width = 100,
this.onTap,
this.zoom = 8,
this.assetMarkerRemoteId,
this.showMarkerPin = false,
this.themeMode,
this.showAttribution = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude);
final controller = useRef<MaplibreMapController?>(null);
final position = useValueNotifier<Point<num>?>(null);
Future<void> onMapCreated(MaplibreMapController mapController) async {
controller.value = mapController;
if (assetMarkerRemoteId != null) {
// The iOS impl returns wrong toScreenLocation without the delay
Future.delayed(
const Duration(milliseconds: 100),
() async =>
position.value = await mapController.toScreenLocation(centre),
);
}
}
Future<void> onStyleLoaded() async {
if (showMarkerPin && controller.value != null) {
await controller.value?.addMarkerAtLatLng(centre);
}
}
return MapThemeOveride(
themeMode: themeMode,
mapBuilder: (style) => SizedBox(
height: height,
width: width,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: Stack(
alignment: Alignment.center,
children: [
style.widgetWhen(
onData: (style) => MaplibreMap(
initialCameraPosition:
CameraPosition(target: offsettedCentre, zoom: zoom),
styleString: style,
onMapCreated: onMapCreated,
onStyleLoadedCallback: onStyleLoaded,
onMapClick: onTap,
doubleClickZoomEnabled: false,
dragEnabled: false,
zoomGesturesEnabled: false,
tiltGesturesEnabled: false,
scrollGesturesEnabled: false,
rotateGesturesEnabled: false,
myLocationEnabled: false,
attributionButtonMargins:
showAttribution == false ? const Point(-100, 0) : null,
),
),
ValueListenableBuilder(
valueListenable: position,
builder: (_, value, __) => value != null
? PositionedAssetMarkerIcon(
size: height / 2,
point: value,
assetRemoteId: assetMarkerRemoteId!,
)
: const SizedBox.shrink(),
),
],
),
),
),
);
}
}

View File

@ -1,17 +1,57 @@
import 'dart:io';
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class AssetMarkerIcon extends StatelessWidget {
const AssetMarkerIcon({
class PositionedAssetMarkerIcon extends StatelessWidget {
final Point<num> point;
final String assetRemoteId;
final double size;
final int durationInMilliseconds;
final Function()? onTap;
const PositionedAssetMarkerIcon({
required this.point,
required this.assetRemoteId,
this.size = 100,
this.durationInMilliseconds = 100,
this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
final ratio = Platform.isIOS ? 1.0 : MediaQuery.devicePixelRatioOf(context);
return AnimatedPositioned(
left: point.x / ratio - size / 2,
top: point.y / ratio - size,
duration: Duration(milliseconds: durationInMilliseconds),
child: GestureDetector(
onTap: () => onTap?.call(),
child: SizedBox.square(
dimension: size,
child: _AssetMarkerIcon(
id: assetRemoteId,
key: Key(assetRemoteId),
),
),
),
);
}
}
class _AssetMarkerIcon extends StatelessWidget {
const _AssetMarkerIcon({
required this.id,
this.isDarkTheme = false,
super.key,
});
final String id;
final bool isDarkTheme;
@override
Widget build(BuildContext context) {
@ -26,8 +66,8 @@ class AssetMarkerIcon extends StatelessWidget {
left: constraints.maxWidth * 0.5,
child: CustomPaint(
painter: _PinPainter(
primaryColor: isDarkTheme ? Colors.white : Colors.black,
secondaryColor: isDarkTheme ? Colors.black : Colors.white,
primaryColor: context.colorScheme.onSurface,
secondaryColor: context.colorScheme.surface,
primaryRadius: constraints.maxHeight * 0.06,
secondaryRadius: constraints.maxHeight * 0.038,
),
@ -42,7 +82,7 @@ class AssetMarkerIcon extends StatelessWidget {
left: constraints.maxWidth * 0.17,
child: CircleAvatar(
radius: constraints.maxHeight * 0.40,
backgroundColor: isDarkTheme ? Colors.white : Colors.black,
backgroundColor: context.colorScheme.onSurface,
child: CircleAvatar(
radius: constraints.maxHeight * 0.37,
backgroundImage: CachedNetworkImageProvider(
@ -72,8 +112,8 @@ class _PinPainter extends CustomPainter {
final double secondaryRadius;
_PinPainter({
this.primaryColor = Colors.black,
this.secondaryColor = Colors.white,
required this.primaryColor,
required this.secondaryColor,
required this.primaryRadius,
required this.secondaryRadius,
});

View File

@ -1,13 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart';
import 'package:immich_mobile/modules/search/ui/curated_row.dart';
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:latlong2/latlong.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class CuratedPlacesRow extends CuratedRow {
final bool isMapEnabled;
@ -38,14 +37,13 @@ class CuratedPlacesRow extends CuratedRow {
padding: const EdgeInsets.only(right: 10.0),
child: MapThumbnail(
zoom: 2,
coords: LatLng(
centre: const LatLng(
47,
5,
),
height: imageSize,
width: imageSize,
showAttribution: false,
isDarkTheme: context.isDarkTheme,
),
),
Padding(

View File

@ -46,7 +46,7 @@ enum AppSettingsEnum<T> {
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
mapThemeMode<bool>(StoreKey.mapThemeMode, null, false),
mapThemeMode<int>(StoreKey.mapThemeMode, null, 0),
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),

View File

@ -9,7 +9,7 @@ import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
import 'package:immich_mobile/modules/album/views/library_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_options_page.dart';
import 'package:immich_mobile/modules/map/ui/map_location_picker.dart';
import 'package:immich_mobile/modules/map/views/map_location_picker_page.dart';
import 'package:immich_mobile/modules/map/views/map_page.dart';
import 'package:immich_mobile/modules/memories/models/memory.dart';
import 'package:immich_mobile/modules/memories/views/memory_page.dart';
@ -59,8 +59,8 @@ import 'package:immich_mobile/shared/views/app_log_page.dart';
import 'package:immich_mobile/shared/views/splash_screen.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:isar/isar.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:photo_manager/photo_manager.dart' hide LatLng;
import 'package:latlong2/latlong.dart';
part 'router.gr.dart';

View File

@ -1593,7 +1593,7 @@ class ActivitiesRoute extends PageRouteInfo<void> {
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
MapLocationPickerRoute({
Key? key,
LatLng? initialLatLng,
LatLng initialLatLng = const LatLng(0, 0),
}) : super(
MapLocationPickerRoute.name,
path: '/map-location-picker-page',
@ -1609,12 +1609,12 @@ class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
class MapLocationPickerRouteArgs {
const MapLocationPickerRouteArgs({
this.key,
this.initialLatLng,
this.initialLatLng = const LatLng(0, 0),
});
final Key? key;
final LatLng? initialLatLng;
final LatLng initialLatLng;
@override
String toString() {

View File

@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
part 'store.g.dart';
@ -8,6 +9,7 @@ part 'store.g.dart';
/// Supports String, int and JSON-serializable Objects
/// Can be used concurrently from multiple isolates
class Store {
static final Logger _log = Logger("Store");
static late final Isar _db;
static final List<dynamic> _cache =
List.filled(StoreKey.values.map((e) => e.id).max + 1, null);
@ -72,8 +74,12 @@ class Store {
static void _onChangeListener(List<StoreValue>? data) {
if (data != null) {
for (StoreValue value in data) {
_cache[value.id] =
value._extract(StoreKey.values.firstWhere((e) => e.id == value.id));
final key = StoreKey.values.firstWhereOrNull((e) => e.id == value.id);
if (key != null) {
_cache[value.id] = value._extract(key);
} else {
_log.warning("No key available for value id - ${value.id}");
}
}
}
}
@ -177,13 +183,13 @@ enum StoreKey<T> {
logLevel<int>(115, type: int),
preferRemoteImage<bool>(116, type: bool),
// map related settings
mapThemeMode<bool>(117, type: bool),
mapShowFavoriteOnly<bool>(118, type: bool),
mapRelativeDate<int>(119, type: int),
selfSignedCert<bool>(120, type: bool),
mapIncludeArchived<bool>(121, type: bool),
ignoreIcloudAssets<bool>(122, type: bool),
selectedAlbumSortReverse<bool>(123, type: bool),
mapThemeMode<int>(124, type: int),
;
const StoreKey(

View File

@ -94,7 +94,8 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
final _log = Logger('WebsocketNotifier');
final Ref _ref;
final Debounce _debounce = Debounce(const Duration(milliseconds: 500));
final Debouncer _debounce =
Debouncer(interval: const Duration(milliseconds: 500));
/// Connects websocket to server unless already connected
void connect() {
@ -194,7 +195,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
PendingChange(now.millisecondsSinceEpoch.toString(), action, value),
],
);
_debounce(handlePendingChanges);
_debounce.run(handlePendingChanges);
}
Future<void> _handlePendingDeletes() async {

View File

@ -11,8 +11,8 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:isar/isar.dart';
import 'package:latlong2/latlong.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
final assetServiceProvider = Provider(

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class CustomDraggingHandle extends StatelessWidget {
const CustomDraggingHandle({super.key});
@ -6,11 +7,11 @@ class CustomDraggingHandle extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
height: 5,
height: 4,
width: 30,
decoration: BoxDecoration(
color: Colors.grey[500],
borderRadius: BorderRadius.circular(16),
color: context.themeData.dividerColor,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
);
}

View File

@ -3,12 +3,11 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_map/plugin_api.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:latlong2/latlong.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
Future<LatLng?> showLocationPicker({
required BuildContext context,
@ -25,16 +24,6 @@ Future<LatLng?> showLocationPicker({
enum _LocationPickerMode { map, manual }
bool _validateLat(String value) {
final l = double.tryParse(value);
return l != null && l > -90 && l < 90;
}
bool _validateLong(String value) {
final l = double.tryParse(value);
return l != null && l > -180 && l < 180;
}
class _LocationPicker extends HookWidget {
final LatLng? initialLatLng;
@ -48,187 +37,35 @@ class _LocationPicker extends HookWidget {
final longitude = useState(initialLatLng?.longitude ?? 0.0);
final latlng = LatLng(latitude.value, longitude.value);
final pickerMode = useState(_LocationPickerMode.map);
final latitudeController = useTextEditingController();
final isValidLatitude = useState(true);
final latitiudeFocusNode = useFocusNode();
final longitudeController = useTextEditingController();
final longitudeFocusNode = useFocusNode();
final isValidLongitude = useState(true);
void validateInputs() {
isValidLatitude.value = _validateLat(latitudeController.text);
if (isValidLatitude.value) {
latitude.value = latitudeController.text.toDouble();
Future<void> onMapTap() async {
final newLatLng = await context.pushRoute<LatLng?>(
MapLocationPickerRoute(initialLatLng: latlng),
);
if (newLatLng != null) {
latitude.value = newLatLng.latitude;
longitude.value = newLatLng.longitude;
}
isValidLongitude.value = _validateLong(longitudeController.text);
if (isValidLongitude.value) {
longitude.value = longitudeController.text.toDouble();
}
}
void validateAndPop() {
if (pickerMode.value == _LocationPickerMode.manual) {
validateInputs();
}
if (isValidLatitude.value && isValidLongitude.value) {
return context.pop(latlng);
}
}
List<Widget> buildMapPickerMode() {
return [
TextButton.icon(
icon: Text(
"${latitude.value.toStringAsFixed(4)}, ${longitude.value.toStringAsFixed(4)}",
),
label: const Icon(Icons.edit_outlined, size: 16),
onPressed: () {
latitudeController.text = latitude.value.toStringAsFixed(4);
longitudeController.text = longitude.value.toStringAsFixed(4);
pickerMode.value = _LocationPickerMode.manual;
},
),
const SizedBox(
height: 12,
),
MapThumbnail(
coords: latlng,
height: 200,
width: 200,
zoom: 6,
showAttribution: false,
onTap: (p0, p1) async {
final newLatLng = await context.pushRoute<LatLng?>(
MapLocationPickerRoute(initialLatLng: latlng),
);
if (newLatLng != null) {
latitude.value = newLatLng.latitude;
longitude.value = newLatLng.longitude;
}
},
markers: [
Marker(
anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng(
latitude.value,
longitude.value,
),
builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'),
),
),
],
),
];
}
List<Widget> buildManualPickerMode() {
return [
TextButton.icon(
icon: const Text("location_picker_choose_on_map").tr(),
label: const Icon(Icons.map_outlined, size: 16),
onPressed: () {
validateInputs();
if (isValidLatitude.value && isValidLongitude.value) {
pickerMode.value = _LocationPickerMode.map;
}
},
),
const SizedBox(
height: 12,
),
TextField(
controller: latitudeController,
focusNode: latitiudeFocusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: 'location_picker_latitude'.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
floatingLabelBehavior: FloatingLabelBehavior.auto,
border: const OutlineInputBorder(),
hintText: 'location_picker_latitude_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
errorText: isValidLatitude.value
? null
: "location_picker_latitude_error".tr(),
),
onEditingComplete: () {
isValidLatitude.value = _validateLat(latitudeController.text);
if (isValidLatitude.value) {
latitude.value = latitudeController.text.toDouble();
longitudeFocusNode.requestFocus();
}
},
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [LengthLimitingTextInputFormatter(8)],
onTapOutside: (_) => latitiudeFocusNode.unfocus(),
),
const SizedBox(
height: 24,
),
TextField(
controller: longitudeController,
focusNode: longitudeFocusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: 'location_picker_longitude'.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
floatingLabelBehavior: FloatingLabelBehavior.auto,
border: const OutlineInputBorder(),
hintText: 'location_picker_longitude_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
errorText: isValidLongitude.value
? null
: "location_picker_longitude_error".tr(),
),
onEditingComplete: () {
isValidLongitude.value = _validateLong(longitudeController.text);
if (isValidLongitude.value) {
longitude.value = longitudeController.text.toDouble();
longitudeFocusNode.unfocus();
}
},
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [LengthLimitingTextInputFormatter(8)],
onTapOutside: (_) => longitudeFocusNode.unfocus(),
),
];
}
return AlertDialog(
contentPadding: const EdgeInsets.all(30),
alignment: Alignment.center,
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"edit_location_dialog_title",
textAlign: TextAlign.center,
).tr(),
const SizedBox(
height: 12,
),
if (pickerMode.value == _LocationPickerMode.manual)
...buildManualPickerMode(),
if (pickerMode.value == _LocationPickerMode.map)
...buildMapPickerMode(),
],
),
child: pickerMode.value == _LocationPickerMode.map
? _MapPicker(
key: ValueKey(latlng),
latlng: latlng,
onModeSwitch: () =>
pickerMode.value = _LocationPickerMode.manual,
onMapTap: onMapTap,
)
: _ManualPicker(
latlng: latlng,
onModeSwitch: () => pickerMode.value = _LocationPickerMode.map,
onLatUpdated: (value) => latitude.value = value,
onLonUpdated: (value) => longitude.value = value,
),
),
actions: [
TextButton(
@ -242,7 +79,7 @@ class _LocationPicker extends HookWidget {
).tr(),
),
TextButton(
onPressed: validateAndPop,
onPressed: () => context.popRoute(latlng),
child: Text(
"action_common_update",
style: context.textTheme.bodyMedium?.copyWith(
@ -255,3 +92,177 @@ class _LocationPicker extends HookWidget {
);
}
}
class _ManualPickerInput extends HookWidget {
final String initialValue;
final String decorationText;
final String hintText;
final String errorText;
final FocusNode focusNode;
final bool Function(String value) validator;
final Function(double value) onUpdated;
const _ManualPickerInput({
required this.initialValue,
required this.decorationText,
required this.hintText,
required this.errorText,
required this.focusNode,
required this.validator,
required this.onUpdated,
});
@override
Widget build(BuildContext context) {
final isValid = useState(true);
final controller = useTextEditingController(text: initialValue);
void onEditingComplete() {
isValid.value = validator(controller.text);
if (isValid.value) {
onUpdated(controller.text.toDouble());
}
}
return TextField(
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: decorationText.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: context.primaryColor,
),
floatingLabelBehavior: FloatingLabelBehavior.auto,
border: const OutlineInputBorder(),
hintText: hintText.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
errorText: isValid.value ? null : errorText.tr(),
),
onEditingComplete: onEditingComplete,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [LengthLimitingTextInputFormatter(8)],
onTapOutside: (_) => focusNode.unfocus(),
);
}
}
class _ManualPicker extends HookWidget {
final LatLng latlng;
final Function() onModeSwitch;
final Function(double) onLatUpdated;
final Function(double) onLonUpdated;
const _ManualPicker({
required this.latlng,
required this.onModeSwitch,
required this.onLatUpdated,
required this.onLonUpdated,
});
bool _validateLat(String value) {
final l = double.tryParse(value);
return l != null && l > -90 && l < 90;
}
bool _validateLong(String value) {
final l = double.tryParse(value);
return l != null && l > -180 && l < 180;
}
@override
Widget build(BuildContext context) {
final latitiudeFocusNode = useFocusNode();
final longitudeFocusNode = useFocusNode();
void onLatitudeUpdated(double value) {
onLatUpdated(value);
longitudeFocusNode.requestFocus();
}
void onLongitudeEditingCompleted(double value) {
onLonUpdated(value);
longitudeFocusNode.unfocus();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"edit_location_dialog_title",
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 12),
TextButton.icon(
icon: const Text("location_picker_choose_on_map").tr(),
label: const Icon(Icons.map_outlined, size: 16),
onPressed: onModeSwitch,
),
const SizedBox(height: 12),
_ManualPickerInput(
initialValue: latlng.latitude.toStringAsFixed(4),
decorationText: "location_picker_latitude",
hintText: "location_picker_latitude_hint",
errorText: "location_picker_latitude_error",
focusNode: latitiudeFocusNode,
validator: _validateLat,
onUpdated: onLatitudeUpdated,
),
const SizedBox(height: 24),
_ManualPickerInput(
initialValue: latlng.longitude.toStringAsFixed(4),
decorationText: "location_picker_longitude",
hintText: "location_picker_longitude_hint",
errorText: "location_picker_longitude_error",
focusNode: latitiudeFocusNode,
validator: _validateLong,
onUpdated: onLongitudeEditingCompleted,
),
],
);
}
}
class _MapPicker extends StatelessWidget {
final LatLng latlng;
final Function() onModeSwitch;
final Function() onMapTap;
const _MapPicker({
required this.latlng,
required this.onModeSwitch,
required this.onMapTap,
super.key,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"edit_location_dialog_title",
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 12),
TextButton.icon(
icon: Text(
"${latlng.latitude.toStringAsFixed(4)}, ${latlng.longitude.toStringAsFixed(4)}",
),
label: const Icon(Icons.edit_outlined, size: 16),
onPressed: onModeSwitch,
),
const SizedBox(height: 12),
MapThumbnail(
centre: latlng,
height: 200,
width: 200,
zoom: 8,
showMarkerPin: true,
onTap: (_, __) => onMapTap(),
),
],
);
}
}

View File

@ -1,26 +1,61 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class Debounce {
Debounce(Duration interval) : _interval = interval.inMilliseconds;
final int _interval;
/// Used to debounce function calls with the [interval] provided.
class Debouncer {
Debouncer({required this.interval});
final Duration interval;
Timer? _timer;
VoidCallback? action;
FutureOr<void> Function()? _lastAction;
void call(VoidCallback? action) {
this.action = action;
void run(FutureOr<void> Function() action) {
_lastAction = action;
_timer?.cancel();
_timer = Timer(Duration(milliseconds: _interval), _callAndRest);
_timer = Timer(interval, _callAndRest);
}
void _callAndRest() {
action?.call();
_lastAction?.call();
_timer = null;
}
void dispose() {
_timer?.cancel();
_timer = null;
_lastAction = null;
}
}
/// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a
/// default interval of 300ms is used to debounce the function calls
Debouncer useDebouncer({
Duration interval = const Duration(milliseconds: 300),
List<Object?>? keys,
}) =>
use(_DebouncerHook(interval: interval, keys: keys));
class _DebouncerHook extends Hook<Debouncer> {
const _DebouncerHook({
required this.interval,
List<Object?>? keys,
}) : super(keys: keys);
final Duration interval;
@override
HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
}
class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
late final debouncer = Debouncer(interval: hook.interval);
@override
Debouncer build(_) => debouncer;
@override
void dispose() => debouncer.dispose();
@override
String get debugLabel => 'useDebouncer';
}

View File

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Creates a [DraggableScrollableController] that will be disposed automatically.
///
/// See also:
/// - [DraggableScrollableController]
DraggableScrollableController useDraggableScrollController({
List<Object?>? keys,
}) {
return use(
_DraggableScrollControllerHook(
keys: keys,
),
);
}
class _DraggableScrollControllerHook
extends Hook<DraggableScrollableController> {
const _DraggableScrollControllerHook({
List<Object?>? keys,
}) : super(keys: keys);
@override
HookState<DraggableScrollableController, Hook<DraggableScrollableController>>
createState() => _DraggableScrollControllerHookState();
}
class _DraggableScrollControllerHookState extends HookState<
DraggableScrollableController, _DraggableScrollControllerHook> {
late final controller = DraggableScrollableController();
@override
DraggableScrollableController build(BuildContext context) => controller;
@override
void dispose() => controller.dispose();
@override
String get debugLabel => 'useDraggableScrollController';
}

View File

@ -12,7 +12,7 @@ import 'package:immich_mobile/shared/ui/date_time_picker.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/location_picker.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:latlong2/latlong.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
void handleShareAssets(
WidgetRef ref,

View File

@ -0,0 +1,57 @@
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Throttles function calls with the [interval] provided.
/// Also make sures to call the last Action after the elapsed interval
class Throttler {
final Duration interval;
DateTime? _lastActionTime;
Throttler({required this.interval});
void run(FutureOr<void> Function() action) {
if (_lastActionTime == null ||
(DateTime.now().difference(_lastActionTime!) > interval)) {
action();
_lastActionTime = DateTime.now();
}
}
void dispose() {
_lastActionTime = null;
}
}
/// Creates a [Throttler] that will be disposed automatically. If no [interval] is provided, a
/// default interval of 300ms is used to throttle the function calls
Throttler useThrottler({
Duration interval = const Duration(milliseconds: 300),
List<Object?>? keys,
}) =>
use(_ThrottleHook(interval: interval, keys: keys));
class _ThrottleHook extends Hook<Throttler> {
const _ThrottleHook({
required this.interval,
List<Object?>? keys,
}) : super(keys: keys);
final Duration interval;
@override
HookState<Throttler, Hook<Throttler>> createState() => _ThrottlerHookState();
}
class _ThrottlerHookState extends HookState<Throttler, _ThrottleHook> {
late final throttler = Throttler(interval: hook.interval);
@override
Throttler build(_) => throttler;
@override
void dispose() => throttler.dispose();
@override
String get debugLabel => 'useThrottler';
}

View File

@ -25,14 +25,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.11.2"
ansicolor:
dependency: transitive
description:
name: ansicolor
sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
archive:
dependency: transitive
description:
name: archive
sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a"
sha256: "7b875fd4a20b165a3084bd2d210439b22ebc653f21cea4842729c0c30c82596b"
url: "https://pub.dev"
source: hosted
version: "3.3.7"
version: "3.4.9"
args:
dependency: transitive
description:
@ -385,14 +393,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.0.2"
executor_lib:
dependency: transitive
description:
name: executor_lib
sha256: "544889daa5726462657dab6410b75f2f8e3a77479d85b307a25c346e243bc38e"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fake_async:
dependency: transitive
description:
@ -503,10 +503,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "559c600f056e7c704bd843723c21e01b5fba47e8824bd02422165bcc02a5de1d"
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev"
source: hosted
version: "0.9.3"
version: "0.13.1"
flutter_lints:
dependency: "direct dev"
description:
@ -544,30 +544,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_map:
dependency: "direct main"
description:
name: flutter_map
sha256: "52c65a977daae42f9aae6748418dd1535eaf27186e9bac9bf431843082bc75a3"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
flutter_map_heatmap:
dependency: "direct main"
description:
name: flutter_map_heatmap
sha256: "2d16cf5bf41f40a79ae79bcdf2afc92ec45fea0cc311b3a51e3eae661392df88"
url: "https://pub.dev"
source: hosted
version: "0.0.4+2"
flutter_native_splash:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: "6777a3abb974021a39b5fdd2d46a03ca390e03903b6351f21d10e7ecc969f12d"
sha256: "17d9671396fb8ec45ad10f4a975eb8a0f70bedf0fdaf0720b31ea9de6da8c4da"
url: "https://pub.dev"
source: hosted
version: "2.2.16"
version: "2.3.7"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@ -755,10 +739,10 @@ packages:
dependency: transitive
description:
name: image
sha256: "8e9d133755c3e84c73288363e6343157c383a0c6c56fc51afcc5d4d7180306d6"
sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271"
url: "https://pub.dev"
source: hosted
version: "3.3.0"
version: "4.1.3"
image_picker:
dependency: "direct main"
description:
@ -884,14 +868,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.1"
latlong2:
dependency: "direct main"
description:
name: latlong2
sha256: "08ef7282ba9f76e8495e49e2dc4d653015ac929dce5f92b375a415d30b407ea0"
url: "https://pub.dev"
source: hosted
version: "0.8.2"
lints:
dependency: transitive
description:
@ -900,14 +876,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
lists:
dependency: transitive
description:
name: lists
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
logging:
dependency: "direct main"
description:
@ -916,6 +884,33 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
maplibre_gl:
dependency: "direct main"
description:
path: "."
ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
url: "https://github.com/maplibre/flutter-maplibre-gl.git"
source: git
version: "0.18.0"
maplibre_gl_platform_interface:
dependency: transitive
description:
path: maplibre_gl_platform_interface
ref: main
resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
url: "https://github.com/maplibre/flutter-maplibre-gl.git"
source: git
version: "0.18.0"
maplibre_gl_web:
dependency: transitive
description:
path: maplibre_gl_web
ref: main
resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
url: "https://github.com/maplibre/flutter-maplibre-gl.git"
source: git
version: "0.18.0"
matcher:
dependency: transitive
description:
@ -940,14 +935,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
mgrs_dart:
dependency: transitive
description:
name: mgrs_dart
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mime:
dependency: transitive
description:
@ -1163,14 +1150,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.7.3"
polylabel:
dependency: transitive
description:
name: polylabel
sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
pool:
dependency: transitive
description:
@ -1187,22 +1166,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.2.4"
proj4dart:
dependency: transitive
description:
name: proj4dart
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
url: "https://pub.dev"
source: hosted
version: "2.1.0"
protobuf:
dependency: transitive
description:
name: protobuf
sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
provider:
dependency: transitive
description:
@ -1520,14 +1483,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
tuple:
dependency: transitive
description:
name: tuple
sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151
url: "https://pub.dev"
source: hosted
version: "2.0.2"
typed_data:
dependency: transitive
description:
@ -1536,14 +1491,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.2"
unicode:
dependency: transitive
description:
name: unicode
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
universal_io:
dependency: transitive
description:
@ -1624,15 +1571,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
vector_map_tiles:
dependency: "direct main"
description:
path: "."
ref: immich_above_4
resolved-ref: dc685bdbcca2ff2b49b4d0fb77b7bc17fad48608
url: "https://github.com/shenlong-tanwen/flutter-vector-map-tiles.git"
source: git
version: "4.0.0"
vector_math:
dependency: transitive
description:
@ -1641,22 +1579,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
vector_tile:
dependency: transitive
description:
name: vector_tile
sha256: "2ac77f6bbd7ddd97efe059207d67bb7eaf807ab98ad58d99fe41a22c230f44e1"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
vector_tile_renderer:
dependency: transitive
description:
name: vector_tile_renderer
sha256: de212da0f5e48107d3b763a940a428eb1f49d8a4664d41ac0b654f77209a2d0b
url: "https://pub.dev"
source: hosted
version: "4.0.0"
video_player:
dependency: "direct main"
description:
@ -1761,14 +1683,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.4"
wkt_parser:
dependency: transitive
description:
name: wkt_parser
sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
xdg_directories:
dependency: transitive
description:

View File

@ -25,13 +25,12 @@ dependencies:
video_player: ^2.2.18
chewie: ^1.4.0
socket_io_client: ^2.0.0-beta.4-nullsafety.0
flutter_map: ^4.0.0
flutter_map_heatmap: ^0.0.4
# Update it to tag once next stable release
maplibre_gl:
git:
url: https://github.com/maplibre/flutter-maplibre-gl.git
ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
geolocator: ^10.0.0 # used to move to current location in map view
vector_map_tiles:
git:
url: https://github.com/shenlong-tanwen/flutter-vector-map-tiles.git
ref: immich_above_4
flutter_udid: ^2.0.0
package_info_plus: ^4.1.0
url_launcher: ^6.1.3
@ -40,10 +39,9 @@ dependencies:
easy_localization: ^3.0.1
share_plus: ^7.1.0
flutter_displaymode: ^0.4.0
scrollable_positioned_list: ^0.3.4
scrollable_positioned_list: ^0.3.8
path: ^1.8.1
path_provider: ^2.0.11
latlong2: ^0.8.1
collection: ^1.16.0
http_parser: ^4.0.1
flutter_web_auth: ^0.5.0
@ -79,7 +77,7 @@ dev_dependencies:
flutter_lints: ^2.0.1
build_runner: ^2.2.1
auto_route_generator: ^5.0.2
flutter_launcher_icons: "^0.9.2"
flutter_launcher_icons: ^0.13.1
flutter_native_splash: ^2.2.16
isar_generator: *isar_version
integration_test:
@ -117,11 +115,12 @@ flutter:
fonts:
- asset: fonts/overpass/OverpassMono.ttf
flutter_icons:
flutter_launcher_icons:
image_path_android: "assets/immich-logo-no-outline.png"
image_path_ios: "assets/immich-logo-no-outline.png"
android: true # can specify file name here e.g. "ic_launcher"
ios: true # can specify file name here e.g. "My-Launcher-Icon
remove_alpha_ios: true
analyzer:
exclude:

View File

@ -203,7 +203,7 @@ void main() {
late ProviderContainer container;
setUp(() async {
settingsMock = AppSettingsServiceMock();
settingsMock = MockAppSettingsService();
container = TestUtils.createContainer(
overrides: [
appSettingsServiceProvider.overrideWith((ref) => settingsMock),
@ -283,7 +283,7 @@ void main() {
late ProviderContainer container;
setUp(() async {
settingsMock = AppSettingsServiceMock();
settingsMock = MockAppSettingsService();
container = TestUtils.createContainer(
overrides: [
appSettingsServiceProvider.overrideWith((ref) => settingsMock),

View File

@ -0,0 +1,18 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:mocktail/mocktail.dart';
class MockMapStateNotifier extends Notifier<MapState>
with Mock
implements MapStateNotifier {
final MapState initState;
MockMapStateNotifier(this.initState);
@override
MapState build() => initState;
@override
set state(MapState mapState) => super.state = mapState;
}

View File

@ -0,0 +1,165 @@
@Tags(['widget'])
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart';
import '../../test_utils.dart';
import '../../widget_tester_extensions.dart';
import 'map_mocks.dart';
void main() {
late MockMapStateNotifier mapStateNotifier;
late List<Override> overrides;
late MapState mapState;
setUpAll(() async {
TestUtils.init();
});
setUp(() {
mapState = MapState(themeMode: ThemeMode.dark);
mapStateNotifier = MockMapStateNotifier(mapState);
overrides = [mapStateNotifierProvider.overrideWith(() => mapStateNotifier)];
});
testWidgets("Return dark theme style when theme mode is dark",
(tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");
},
),
overrides: overrides,
);
mapStateNotifier.state =
mapState.copyWith(darkStyleFetched: const AsyncData("dark"));
await tester.pumpAndSettle();
expect(mapStyle?.valueOrNull, "dark");
});
testWidgets("Return error when style is not fetched", (tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");
},
),
overrides: overrides,
);
mapStateNotifier.state = mapState.copyWith(
darkStyleFetched: const AsyncError("Error", StackTrace.empty),
);
await tester.pumpAndSettle();
expect(mapStyle?.hasError, isTrue);
});
testWidgets("Return light theme style when theme mode is light",
(tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");
},
),
overrides: overrides,
);
mapStateNotifier.state = mapState.copyWith(
themeMode: ThemeMode.light,
lightStyleFetched: const AsyncData("light"),
);
await tester.pumpAndSettle();
expect(mapStyle?.valueOrNull, "light");
});
group("System mode", () {
testWidgets("Return dark theme style when system is dark", (tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");
},
),
overrides: overrides,
);
tester.binding.platformDispatcher.platformBrightnessTestValue =
Brightness.dark;
mapStateNotifier.state = mapState.copyWith(
themeMode: ThemeMode.system,
darkStyleFetched: const AsyncData("dark"),
);
await tester.pumpAndSettle();
expect(mapStyle?.valueOrNull, "dark");
});
testWidgets("Return light theme style when system is light",
(tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");
},
),
overrides: overrides,
);
tester.binding.platformDispatcher.platformBrightnessTestValue =
Brightness.light;
mapStateNotifier.state = mapState.copyWith(
themeMode: ThemeMode.system,
lightStyleFetched: const AsyncData("light"),
);
await tester.pumpAndSettle();
expect(mapStyle?.valueOrNull, "light");
});
testWidgets("Switches style when system brightness changes",
(tester) async {
AsyncValue<String>? mapStyle;
await tester.pumpConsumerWidget(
MapThemeOveride(
mapBuilder: (AsyncValue<String> style) {
mapStyle = style;
return const Text("Mock");
},
),
overrides: overrides,
);
tester.binding.platformDispatcher.platformBrightnessTestValue =
Brightness.light;
mapStateNotifier.state = mapState.copyWith(
themeMode: ThemeMode.system,
lightStyleFetched: const AsyncData("light"),
darkStyleFetched: const AsyncData("dark"),
);
await tester.pumpAndSettle();
expect(mapStyle?.valueOrNull, "light");
tester.binding.platformDispatcher.platformBrightnessTestValue =
Brightness.dark;
await tester.pumpAndSettle();
expect(mapStyle?.valueOrNull, "dark");
});
});
}

View File

@ -1,4 +1,4 @@
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:mocktail/mocktail.dart';
class AppSettingsServiceMock extends Mock implements AppSettingsService {}
class MockAppSettingsService extends Mock implements AppSettingsService {}

View File

@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/debounce.dart';
class _Counter {
int _count = 0;
_Counter();
int get count => _count;
void increment() => _count = _count + 1;
}
void main() {
test('Executes the method after the interval', () async {
var counter = _Counter();
final debouncer = Debouncer(interval: const Duration(milliseconds: 300));
debouncer.run(() => counter.increment());
expect(counter.count, 0);
await Future.delayed(const Duration(milliseconds: 300));
expect(counter.count, 1);
});
test('Executes the method immediately if zero interval', () async {
var counter = _Counter();
final debouncer = Debouncer(interval: const Duration(milliseconds: 0));
debouncer.run(() => counter.increment());
// Even though it is supposed to be executed immediately, it is added to the async queue and so
// we need this delay to make sure the actual debounced method is called
await Future.delayed(const Duration(milliseconds: 0));
expect(counter.count, 1);
});
test('Delayes method execution after all the calls are completed', () async {
var counter = _Counter();
final debouncer = Debouncer(interval: const Duration(milliseconds: 100));
debouncer.run(() => counter.increment());
debouncer.run(() => counter.increment());
debouncer.run(() => counter.increment());
await Future.delayed(const Duration(milliseconds: 300));
expect(counter.count, 1);
});
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/throttle.dart';
class _Counter {
int _count = 0;
_Counter();
int get count => _count;
void increment() {
debugPrint("Counter inside increment: $count");
_count = _count + 1;
}
}
void main() {
test('Executes the method immediately if no calls received previously',
() async {
var counter = _Counter();
final throttler = Throttler(interval: const Duration(milliseconds: 300));
throttler.run(() => counter.increment());
expect(counter.count, 1);
});
test('Does not execute calls before throttle interval', () async {
var counter = _Counter();
final throttler = Throttler(interval: const Duration(milliseconds: 100));
throttler.run(() => counter.increment());
throttler.run(() => counter.increment());
throttler.run(() => counter.increment());
throttler.run(() => counter.increment());
throttler.run(() => counter.increment());
await Future.delayed(const Duration(seconds: 1));
expect(counter.count, 1);
});
test('Executes the method if received in intervals', () async {
var counter = _Counter();
final throttler = Throttler(interval: const Duration(milliseconds: 100));
for (final _ in Iterable<int>.generate(10)) {
throttler.run(() => counter.increment());
await Future.delayed(const Duration(milliseconds: 50));
}
await Future.delayed(const Duration(seconds: 1));
expect(counter.count, 5);
});
}