mirror of
https://github.com/immich-app/immich.git
synced 2025-06-03 13:44:16 -04:00
feat: sort places by distance
This commit is contained in:
parent
f0ff8581da
commit
ba9e37bad2
@ -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",
|
||||
|
77
mobile/lib/models/places/place_result.model.dart
Normal file
77
mobile/lib/models/places/place_result.model.dart
Normal 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;
|
||||
}
|
@ -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,
|
||||
|
@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -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();
|
||||
|
30
mobile/lib/utils/calculate_distance.dart
Normal file
30
mobile/lib/utils/calculate_distance.dart
Normal 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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user