From ba9e37bad2a920372c6e8c2e0ce202d28f91d4ea Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 21 Apr 2025 12:03:28 +0200 Subject: [PATCH] feat: sort places by distance --- i18n/en.json | 2 + .../lib/models/places/place_result.model.dart | 77 +++++++++++++++++++ .../places/places_collection.page.dart | 76 +++++++++++++++++- mobile/lib/pages/search/all_places.page.dart | 13 +++- .../search/search_page_state.provider.dart | 7 +- mobile/lib/utils/calculate_distance.dart | 30 ++++++++ 6 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 mobile/lib/models/places/place_result.model.dart create mode 100644 mobile/lib/utils/calculate_distance.dart diff --git a/i18n/en.json b/i18n/en.json index 9951717de6..4ba5751c36 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -758,6 +758,7 @@ "display_order": "Display order", "display_original_photos": "Display original photos", "display_original_photos_setting_description": "Prefer to display the original photo when viewing an asset rather than thumbnails when the original asset is web-compatible. This may result in slower photo display speeds.", + "distance": "Distance", "do_not_show_again": "Do not show this message again", "documentation": "Documentation", "done": "Done", @@ -1710,6 +1711,7 @@ "sort_modified": "Date modified", "sort_oldest": "Oldest photo", "sort_people_by_similarity": "Sort people by similarity", + "sort_places_by": "Sort places by", "sort_recent": "Most recent photo", "sort_title": "Title", "source": "Source", diff --git a/mobile/lib/models/places/place_result.model.dart b/mobile/lib/models/places/place_result.model.dart new file mode 100644 index 0000000000..40e4cd749c --- /dev/null +++ b/mobile/lib/models/places/place_result.model.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +class PlaceResult { + /// The label to show associated with this curated object + final String label; + + /// The id to lookup the asset from the server + final String id; + + /// The latitude of the location + final double latitude; + + /// The longitude of the location + final double longitude; + + PlaceResult({ + required this.label, + required this.id, + required this.latitude, + required this.longitude, + }); + + PlaceResult copyWith({ + String? label, + String? id, + double? latitude, + double? longitude, + }) { + return PlaceResult( + label: label ?? this.label, + id: id ?? this.id, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } + + Map toMap() { + return { + 'label': label, + 'id': id, + 'latitude': latitude, + 'longitude': longitude, + }; + } + + factory PlaceResult.fromMap(Map map) { + return PlaceResult( + label: map['label'] as String, + id: map['id'] as String, + latitude: map['latitude'] as double, + longitude: map['longitude'] as double, + ); + } + + String toJson() => json.encode(toMap()); + + factory PlaceResult.fromJson(String source) => + PlaceResult.fromMap(json.decode(source) as Map); + + @override + String toString() => + 'CuratedContent(label: $label, id: $id, latitude: $latitude, longitude: $longitude)'; + + @override + bool operator ==(covariant PlaceResult other) { + if (identical(this, other)) return true; + + return other.label == label && + other.id == id && + other.latitude == latitude && + other.longitude == longitude; + } + + @override + int get hashCode => + label.hashCode ^ id.hashCode ^ latitude.hashCode ^ longitude.hashCode; +} diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart index 5f2dea0dec..cfad925716 100644 --- a/mobile/lib/pages/library/places/places_collection.page.dart +++ b/mobile/lib/pages/library/places/places_collection.page.dart @@ -13,19 +13,27 @@ import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/calculate_distance.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +enum FilterType { + name, + distance, +} + @RoutePage() class PlacesCollectionPage extends HookConsumerWidget { const PlacesCollectionPage({super.key, this.currentLocation}); final LatLng? currentLocation; + @override Widget build(BuildContext context, WidgetRef ref) { final places = ref.watch(getAllPlacesProvider); final formFocus = useFocusNode(); final ValueNotifier search = useState(null); + final filterType = useState(FilterType.name); return Scaffold( appBar: AppBar( @@ -52,12 +60,11 @@ class PlacesCollectionPage extends HookConsumerWidget { body: ListView( shrinkWrap: true, children: [ - if (search.value == null) + if (search.value == null) ...[ Padding( padding: const EdgeInsets.all(16.0), child: SizedBox( height: 200, - width: context.width, child: MapThumbnail( onTap: (_, __) => context .pushRoute(MapRoute(initialLocation: currentLocation)), @@ -73,6 +80,51 @@ class PlacesCollectionPage extends HookConsumerWidget { ), ), ), + if (currentLocation != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Row( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text('sort_places_by'.tr()), + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + width: 1.5, + ), + borderRadius: BorderRadius.circular(8.0), + ), + child: DropdownButton( + value: filterType.value, + items: [ + DropdownMenuItem( + value: FilterType.name, + child: Text('name'.tr()), + ), + DropdownMenuItem( + value: FilterType.distance, + child: Text('distance'.tr()), + ), + ], + onChanged: (e) { + filterType.value = e!; + }, + isExpanded: false, + underline: const SizedBox(), + ), + ), + ), + ], + ), + ), + ], places.when( data: (places) { if (search.value != null) { @@ -81,6 +133,26 @@ class PlacesCollectionPage extends HookConsumerWidget { .toLowerCase() .contains(search.value!.toLowerCase()); }).toList(); + } else if (filterType.value == FilterType.distance && + currentLocation != null) { + // Sort places by distance when filterType is distance and search is null + places = List.from(places); + places.sort((a, b) { + final double distanceA = calculateDistance( + currentLocation!.latitude, + currentLocation!.longitude, + a.latitude, + a.longitude, + ); + final double distanceB = calculateDistance( + currentLocation!.latitude, + currentLocation!.longitude, + b.latitude, + b.longitude, + ); + + return distanceA.compareTo(distanceB); + }); } return ListView.builder( shrinkWrap: true, diff --git a/mobile/lib/pages/search/all_places.page.dart b/mobile/lib/pages/search/all_places.page.dart index 92521d13cf..b6e1cdcd82 100644 --- a/mobile/lib/pages/search/all_places.page.dart +++ b/mobile/lib/pages/search/all_places.page.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/models/places/place_result.model.dart'; import 'package:immich_mobile/models/search/search_curated_content.model.dart'; import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; import 'package:immich_mobile/widgets/search/explore_grid.dart'; @@ -13,8 +14,7 @@ class AllPlacesPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - AsyncValue> places = - ref.watch(getAllPlacesProvider); + AsyncValue> places = ref.watch(getAllPlacesProvider); return Scaffold( appBar: AppBar( @@ -28,7 +28,14 @@ class AllPlacesPage extends HookConsumerWidget { ), body: places.widgetWhen( onData: (data) => ExploreGrid( - curatedContent: data, + curatedContent: data + .map( + (e) => SearchCuratedContent( + label: e.label, + id: e.id, + ), + ) + .toList(), ), ), ); diff --git a/mobile/lib/providers/search/search_page_state.provider.dart b/mobile/lib/providers/search/search_page_state.provider.dart index d0e3720c0f..d8414b43fb 100644 --- a/mobile/lib/providers/search/search_page_state.provider.dart +++ b/mobile/lib/providers/search/search_page_state.provider.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/places/place_result.model.dart'; import 'package:immich_mobile/models/search/search_curated_content.model.dart'; import 'package:immich_mobile/services/search.service.dart'; @@ -29,7 +30,7 @@ final getPreviewPlacesProvider = }); final getAllPlacesProvider = - FutureProvider.autoDispose>((ref) async { + FutureProvider.autoDispose>((ref) async { final SearchService searchService = ref.watch(searchServiceProvider); final assetPlaces = await searchService.getAllPlaces(); @@ -40,9 +41,11 @@ final getAllPlacesProvider = final curatedContent = assetPlaces .map( - (data) => SearchCuratedContent( + (data) => PlaceResult( label: data.exifInfo!.city!, id: data.id, + latitude: data.exifInfo!.latitude!.toDouble(), + longitude: data.exifInfo!.longitude!.toDouble(), ), ) .toList(); diff --git a/mobile/lib/utils/calculate_distance.dart b/mobile/lib/utils/calculate_distance.dart new file mode 100644 index 0000000000..6fc1e99926 --- /dev/null +++ b/mobile/lib/utils/calculate_distance.dart @@ -0,0 +1,30 @@ +import 'dart:math'; + +// Add method to calculate distance between two LatLng points using Haversine formula +double calculateDistance( + double? latitude1, + double? longitude1, + double? latitude2, + double? longitude2, +) { + if (latitude1 == null || + longitude1 == null || + latitude2 == null || + longitude2 == null) { + return double.maxFinite; + } + + const int earthRadius = 6371; // Earth's radius in kilometers + final double lat1 = latitude1 * (pi / 180); + final double lat2 = latitude2 * (pi / 180); + final double lon1 = longitude1 * (pi / 180); + final double lon2 = longitude2 * (pi / 180); + + final double dLat = lat2 - lat1; + final double dLon = lon2 - lon1; + + final double a = + pow(sin(dLat / 2), 2) + cos(lat1) * cos(lat2) * pow(sin(dLon / 2), 2); + final double c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return earthRadius * c; +}