mirror of
https://github.com/immich-app/immich.git
synced 2025-10-25 15:52:33 -04:00
* chore: map widget and page styling * fix: map bottom sheet styling * fix: attribution location on android it appears that on android, the attribution marker is positioned from the top of the display and on iOS its positioned from the safe area edge
249 lines
7.5 KiB
Dart
249 lines
7.5 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/material.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/extensions/translate_extensions.dart';
|
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
|
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
|
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
|
import 'package:immich_mobile/utils/debounce.dart';
|
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
|
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
|
|
|
class CustomSourceProperties implements SourceProperties {
|
|
final Map<String, dynamic> data;
|
|
const CustomSourceProperties({required this.data});
|
|
|
|
@override
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
"type": "geojson",
|
|
"data": data,
|
|
// "cluster": true,
|
|
// "clusterRadius": 1,
|
|
// "clusterMinPoints": 5,
|
|
// "tolerance": 0.1,
|
|
};
|
|
}
|
|
}
|
|
|
|
class DriftMap extends ConsumerStatefulWidget {
|
|
final LatLng? initialLocation;
|
|
|
|
const DriftMap({super.key, this.initialLocation});
|
|
|
|
@override
|
|
ConsumerState<DriftMap> createState() => _DriftMapState();
|
|
}
|
|
|
|
class _DriftMapState extends ConsumerState<DriftMap> {
|
|
MapLibreMapController? mapController;
|
|
final _reloadMutex = AsyncMutex();
|
|
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
|
|
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
|
|
|
|
@override
|
|
void dispose() {
|
|
_debouncer.dispose();
|
|
bottomSheetOffset.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void onMapCreated(MapLibreMapController controller) {
|
|
mapController = controller;
|
|
}
|
|
|
|
Future<void> onMapReady() async {
|
|
final controller = mapController;
|
|
if (controller == null) {
|
|
return;
|
|
}
|
|
|
|
await controller.addSource(
|
|
MapUtils.defaultSourceId,
|
|
const CustomSourceProperties(data: {'type': 'FeatureCollection', 'features': []}),
|
|
);
|
|
|
|
if (Platform.isAndroid) {
|
|
await controller.addCircleLayer(
|
|
MapUtils.defaultSourceId,
|
|
MapUtils.defaultHeatMapLayerId,
|
|
const CircleLayerProperties(
|
|
circleRadius: 10,
|
|
circleColor: "rgba(150,86,34,0.7)",
|
|
circleBlur: 1.0,
|
|
circleOpacity: 0.7,
|
|
circleStrokeWidth: 0.1,
|
|
circleStrokeColor: "rgba(203,46,19,0.5)",
|
|
circleStrokeOpacity: 0.7,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (Platform.isIOS) {
|
|
await controller.addHeatmapLayer(
|
|
MapUtils.defaultSourceId,
|
|
MapUtils.defaultHeatMapLayerId,
|
|
MapUtils.defaultHeatmapLayerProperties,
|
|
);
|
|
}
|
|
|
|
_debouncer.run(setBounds);
|
|
controller.addListener(onMapMoved);
|
|
}
|
|
|
|
void onMapMoved() {
|
|
if (mapController!.isCameraMoving || !mounted) {
|
|
return;
|
|
}
|
|
|
|
_debouncer.run(setBounds);
|
|
}
|
|
|
|
Future<void> setBounds() async {
|
|
final controller = mapController;
|
|
if (controller == null || !mounted) {
|
|
return;
|
|
}
|
|
|
|
final bounds = await controller.getVisibleRegion();
|
|
_reloadMutex.run(() async {
|
|
if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) {
|
|
final markers = await ref.read(mapMarkerProvider(bounds).future);
|
|
await reloadMarkers(markers);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> reloadMarkers(Map<String, dynamic> markers) async {
|
|
final controller = mapController;
|
|
if (controller == null || !mounted) {
|
|
return;
|
|
}
|
|
|
|
await controller.setGeoJsonSource(MapUtils.defaultSourceId, markers);
|
|
}
|
|
|
|
Future<void> onZoomToLocation() async {
|
|
final (location, error) = await MapUtils.checkPermAndGetLocation(context: context);
|
|
if (error != null) {
|
|
if (error == LocationPermission.unableToDetermine && context.mounted) {
|
|
ImmichToast.show(
|
|
context: context,
|
|
gravity: ToastGravity.BOTTOM,
|
|
toastType: ToastType.error,
|
|
msg: "map_cannot_get_user_location".t(context: context),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final controller = mapController;
|
|
if (controller != null && location != null) {
|
|
controller.animateCamera(
|
|
CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), MapUtils.mapZoomToAssetLevel),
|
|
duration: const Duration(milliseconds: 800),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),
|
|
_DynamicBottomSheet(bottomSheetOffset: bottomSheetOffset),
|
|
_DynamicMyLocationButton(onZoomToLocation: onZoomToLocation, bottomSheetOffset: bottomSheetOffset),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Map extends StatelessWidget {
|
|
final LatLng? initialLocation;
|
|
|
|
const _Map({this.initialLocation, required this.onMapCreated, required this.onMapReady});
|
|
|
|
final MapCreatedCallback onMapCreated;
|
|
|
|
final VoidCallback onMapReady;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final initialLocation = this.initialLocation;
|
|
return MapThemeOverride(
|
|
mapBuilder: (style) => style.widgetWhen(
|
|
onData: (style) => MapLibreMap(
|
|
initialCameraPosition: initialLocation == null
|
|
? const CameraPosition(target: LatLng(0, 0), zoom: 0)
|
|
: CameraPosition(target: initialLocation, zoom: MapUtils.mapZoomToAssetLevel),
|
|
styleString: style,
|
|
onMapCreated: onMapCreated,
|
|
onStyleLoadedCallback: onMapReady,
|
|
attributionButtonPosition: AttributionButtonPosition.topRight,
|
|
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DynamicBottomSheet extends StatefulWidget {
|
|
final ValueNotifier<double> bottomSheetOffset;
|
|
|
|
const _DynamicBottomSheet({required this.bottomSheetOffset});
|
|
|
|
@override
|
|
State<_DynamicBottomSheet> createState() => _DynamicBottomSheetState();
|
|
}
|
|
|
|
class _DynamicBottomSheetState extends State<_DynamicBottomSheet> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return NotificationListener<DraggableScrollableNotification>(
|
|
onNotification: (notification) {
|
|
widget.bottomSheetOffset.value = notification.extent;
|
|
return true;
|
|
},
|
|
child: const MapBottomSheet(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DynamicMyLocationButton extends StatelessWidget {
|
|
const _DynamicMyLocationButton({required this.onZoomToLocation, required this.bottomSheetOffset});
|
|
|
|
final VoidCallback onZoomToLocation;
|
|
final ValueNotifier<double> bottomSheetOffset;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ValueListenableBuilder<double>(
|
|
valueListenable: bottomSheetOffset,
|
|
builder: (context, offset, child) {
|
|
return Positioned(
|
|
right: 16,
|
|
bottom: context.height * (offset - 0.02) + context.padding.bottom,
|
|
child: AnimatedOpacity(
|
|
opacity: offset < 0.8 ? 1 : 0,
|
|
duration: const Duration(milliseconds: 150),
|
|
child: ElevatedButton(
|
|
onPressed: onZoomToLocation,
|
|
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
|
|
child: const Icon(Icons.my_location),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|