feat: sort places by distance

This commit is contained in:
Yaros 2025-04-21 12:03:28 +02:00
parent f0ff8581da
commit ba9e37bad2
6 changed files with 198 additions and 7 deletions

View File

@ -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",

View File

@ -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<String, dynamic> toMap() {
return <String, dynamic>{
'label': label,
'id': id,
'latitude': latitude,
'longitude': longitude,
};
}
factory PlaceResult.fromMap(Map<String, dynamic> 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<String, dynamic>);
@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;
}

View File

@ -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<String?> 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,

View File

@ -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<List<SearchCuratedContent>> places =
ref.watch(getAllPlacesProvider);
AsyncValue<List<PlaceResult>> 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(),
),
),
);

View File

@ -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<List<SearchCuratedContent>>((ref) async {
FutureProvider.autoDispose<List<PlaceResult>>((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();

View File

@ -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;
}