mirror of
https://github.com/immich-app/immich.git
synced 2025-12-07 19:53:59 +09:00
feat: Maplibre (#4294)
* maplibre on web, custom styles from server Actually use new vector tile server, custom style.json support multiple style files, light/dark mode cleanup, use new map everywhere send file directly instead of loading first better light/dark mode switching remove leaflet fix mapstyles dto, first draft of map settings delete and add styles fix delete default styles fix tests only allow one light and one dark style url revert config core changes fix server config store fix tests move axios fetches to repo fix package-lock fix tests * open api * add assets to docker container * web: use mapSettings color for style * style: add unique ids to map styles * mobile: use style json for vector / raster * do not use svelte-material-icons * add click events to markers, simplify asset detail map * improve map performance by using asset thumbnails for markers instead of original file * Remove custom attribution (by request) * mobile: update map attribution * style: map dark mode * style: map light mode * zoom level for state * styling * overflow gradient * Limit maxZoom to 14 * mobile: listen for mapStyle changes in MapThumbnail * mobile: update concurrency --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: bo0tzz <git@bo0tzz.me> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
import 'package:vector_map_tiles/vector_map_tiles.dart';
|
||||
|
||||
class MapState {
|
||||
final bool isDarkTheme;
|
||||
final bool showFavoriteOnly;
|
||||
final bool includeArchived;
|
||||
final int relativeTime;
|
||||
final Style? mapStyle;
|
||||
final bool isLoading;
|
||||
|
||||
MapState({
|
||||
this.isDarkTheme = false,
|
||||
this.showFavoriteOnly = false,
|
||||
this.includeArchived = false,
|
||||
this.relativeTime = 0,
|
||||
this.mapStyle,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
MapState copyWith({
|
||||
@@ -16,18 +22,22 @@ class MapState {
|
||||
bool? showFavoriteOnly,
|
||||
bool? includeArchived,
|
||||
int? relativeTime,
|
||||
Style? mapStyle,
|
||||
bool? isLoading,
|
||||
}) {
|
||||
return MapState(
|
||||
isDarkTheme: isDarkTheme ?? this.isDarkTheme,
|
||||
showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
relativeTime: relativeTime ?? this.relativeTime,
|
||||
mapStyle: mapStyle ?? this.mapStyle,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived)';
|
||||
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived, mapStyle: $mapStyle, isLoading: $isLoading)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -38,7 +48,9 @@ class MapState {
|
||||
other.isDarkTheme == isDarkTheme &&
|
||||
other.showFavoriteOnly == showFavoriteOnly &&
|
||||
other.relativeTime == relativeTime &&
|
||||
other.includeArchived == includeArchived;
|
||||
other.includeArchived == includeArchived &&
|
||||
other.mapStyle == mapStyle &&
|
||||
other.isLoading == isLoading;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -46,6 +58,8 @@ class MapState {
|
||||
return isDarkTheme.hashCode ^
|
||||
showFavoriteOnly.hashCode ^
|
||||
relativeTime.hashCode ^
|
||||
includeArchived.hashCode;
|
||||
includeArchived.hashCode ^
|
||||
mapStyle.hashCode ^
|
||||
isLoading.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
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';
|
||||
|
||||
class MapStateNotifier extends StateNotifier<MapState> {
|
||||
MapStateNotifier(this._appSettingsProvider)
|
||||
MapStateNotifier(this._appSettingsProvider, this._apiService)
|
||||
: super(
|
||||
MapState(
|
||||
isDarkTheme: _appSettingsProvider
|
||||
@@ -15,17 +28,69 @@ class MapStateNotifier extends StateNotifier<MapState> {
|
||||
.getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
|
||||
relativeTime: _appSettingsProvider
|
||||
.getSetting<int>(AppSettingsEnum.mapRelativeDate),
|
||||
isLoading: true,
|
||||
),
|
||||
);
|
||||
) {
|
||||
_fetchStyleFromServer(
|
||||
_appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapThemeMode),
|
||||
);
|
||||
}
|
||||
|
||||
final AppSettingsService _appSettingsProvider;
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("MapStateNotifier");
|
||||
|
||||
bool get isRaster =>
|
||||
state.mapStyle != null && state.mapStyle!.rasterTileProvider != null;
|
||||
|
||||
double get maxZoom =>
|
||||
(isRaster ? state.mapStyle!.rasterTileProvider!.maximumZoom : 14)
|
||||
.toDouble();
|
||||
|
||||
void switchTheme(bool isDarkTheme) {
|
||||
_updateThemeMode(isDarkTheme);
|
||||
_fetchStyleFromServer(isDarkTheme);
|
||||
}
|
||||
|
||||
void _updateThemeMode(bool isDarkTheme) {
|
||||
_appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapThemeMode,
|
||||
isDarkTheme,
|
||||
);
|
||||
state = state.copyWith(isDarkTheme: 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');
|
||||
return;
|
||||
}
|
||||
final styleJson = await compute(jsonDecode, styleJsonString);
|
||||
if (styleJson is! Map<String, dynamic>) {
|
||||
_log.severe('Style JSON from server is invalid');
|
||||
return;
|
||||
}
|
||||
final styleReader = StyleReader(uri: '');
|
||||
Style? style;
|
||||
try {
|
||||
style = await styleReader.readFromMap(styleJson);
|
||||
} finally {
|
||||
// Consume all error
|
||||
}
|
||||
state = state.copyWith(
|
||||
mapStyle: style,
|
||||
isLoading: false,
|
||||
);
|
||||
}
|
||||
|
||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||
@@ -51,9 +116,44 @@ class MapStateNotifier extends StateNotifier<MapState> {
|
||||
);
|
||||
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));
|
||||
return MapStateNotifier(
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(apiServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ 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';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MapPageBottomSheet extends StatefulHookConsumerWidget {
|
||||
final Stream mapPageEventStream;
|
||||
@@ -320,24 +319,18 @@ class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
|
||||
Positioned(
|
||||
bottom: maxHeight * currentExtend.value,
|
||||
left: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
),
|
||||
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],
|
||||
),
|
||||
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],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
@@ -29,11 +28,7 @@ class MapThumbnail extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: ref.watch(
|
||||
serverInfoProvider.select((v) => v.serverConfig.mapTileUrl),
|
||||
),
|
||||
);
|
||||
ref.watch(mapStateNotifier.select((s) => s.mapStyle));
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
@@ -55,20 +50,14 @@ class MapThumbnail extends HookConsumerWidget {
|
||||
'OpenStreetMap contributors',
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
children: [
|
||||
isDarkTheme
|
||||
? InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: tileLayer,
|
||||
),
|
||||
)
|
||||
: tileLayer,
|
||||
ref.read(mapStateNotifier.notifier).getTileLayer(isDarkTheme),
|
||||
if (markers.isNotEmpty) MarkerLayer(markers: markers),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -20,10 +21,8 @@ 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/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/flutter_map_extensions.dart';
|
||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||
@@ -79,21 +78,25 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
Set<AssetMarkerData>? assetMarkers, {
|
||||
bool forceReload = false,
|
||||
}) {
|
||||
final bounds = mapController.bounds;
|
||||
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(),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final bounds = mapController.bounds;
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Consume all error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +123,10 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
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(
|
||||
@@ -168,7 +175,6 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
final mapMarker = mapMarkerData.value
|
||||
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
|
||||
if (mapMarker != null) {
|
||||
const zoomLevel = 16.0;
|
||||
LatLng? newCenter = mapController.centerBoundsWithPadding(
|
||||
mapMarker.point,
|
||||
const Offset(0, -120),
|
||||
@@ -230,7 +236,7 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
forceAssetUpdate = true;
|
||||
mapController.move(
|
||||
LatLng(currentUserLocation.latitude, currentUserLocation.longitude),
|
||||
12,
|
||||
zoomLevel,
|
||||
);
|
||||
} catch (error) {
|
||||
log.severe(
|
||||
@@ -359,24 +365,6 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
selectedAssets.value = selection;
|
||||
}
|
||||
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: ref.watch(
|
||||
serverInfoProvider.select((v) => v.serverConfig.mapTileUrl),
|
||||
),
|
||||
maxNativeZoom: 19,
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
final darkTileLayer = InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: BrightnessFilter(
|
||||
brightness: -1,
|
||||
child: tileLayer,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final markerLayer = MarkerLayer(
|
||||
markers: [
|
||||
if (closestAssetMarker.value != null)
|
||||
@@ -451,38 +439,40 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
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: 18, // max level supported by OSM,
|
||||
onMapReady: () {
|
||||
mapController.mapEventStream.listen(onMapEvent);
|
||||
},
|
||||
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: () {
|
||||
mapController.mapEventStream.listen(onMapEvent);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
ref.read(mapStateNotifier.notifier).getTileLayer(),
|
||||
heatMapLayer,
|
||||
markerLayer,
|
||||
],
|
||||
),
|
||||
children: [
|
||||
isDarkTheme ? darkTileLayer : tileLayer,
|
||||
heatMapLayer,
|
||||
markerLayer,
|
||||
],
|
||||
),
|
||||
MapPageBottomSheet(
|
||||
mapPageEventStream: mapPageEventSC.stream,
|
||||
bottomSheetEventSC: bottomSheetEventSC,
|
||||
selectionEnabled: selectionEnabledHook.value,
|
||||
selectionlistener: selectionListener,
|
||||
isDarkTheme: isDarkTheme,
|
||||
),
|
||||
if (showLoadingIndicator.value)
|
||||
if (!isLoading)
|
||||
MapPageBottomSheet(
|
||||
mapPageEventStream: mapPageEventSC.stream,
|
||||
bottomSheetEventSC: bottomSheetEventSC,
|
||||
selectionEnabled: selectionEnabledHook.value,
|
||||
selectionlistener: selectionListener,
|
||||
isDarkTheme: isDarkTheme,
|
||||
),
|
||||
if (showLoadingIndicator.value || isLoading)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).size.height * 0.35,
|
||||
left: MediaQuery.of(context).size.width * 0.425,
|
||||
|
||||
Reference in New Issue
Block a user