mirror of
https://github.com/immich-app/immich.git
synced 2025-06-11 01:24:32 -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_order": "Display order",
|
||||||
"display_original_photos": "Display original photos",
|
"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.",
|
"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",
|
"do_not_show_again": "Do not show this message again",
|
||||||
"documentation": "Documentation",
|
"documentation": "Documentation",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
@ -1710,6 +1711,7 @@
|
|||||||
"sort_modified": "Date modified",
|
"sort_modified": "Date modified",
|
||||||
"sort_oldest": "Oldest photo",
|
"sort_oldest": "Oldest photo",
|
||||||
"sort_people_by_similarity": "Sort people by similarity",
|
"sort_people_by_similarity": "Sort people by similarity",
|
||||||
|
"sort_places_by": "Sort places by",
|
||||||
"sort_recent": "Most recent photo",
|
"sort_recent": "Most recent photo",
|
||||||
"sort_title": "Title",
|
"sort_title": "Title",
|
||||||
"source": "Source",
|
"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/providers/search/search_page_state.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/services/api.service.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/common/search_field.dart';
|
||||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
|
enum FilterType {
|
||||||
|
name,
|
||||||
|
distance,
|
||||||
|
}
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class PlacesCollectionPage extends HookConsumerWidget {
|
class PlacesCollectionPage extends HookConsumerWidget {
|
||||||
const PlacesCollectionPage({super.key, this.currentLocation});
|
const PlacesCollectionPage({super.key, this.currentLocation});
|
||||||
final LatLng? currentLocation;
|
final LatLng? currentLocation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final places = ref.watch(getAllPlacesProvider);
|
final places = ref.watch(getAllPlacesProvider);
|
||||||
final formFocus = useFocusNode();
|
final formFocus = useFocusNode();
|
||||||
final ValueNotifier<String?> search = useState(null);
|
final ValueNotifier<String?> search = useState(null);
|
||||||
|
final filterType = useState(FilterType.name);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@ -52,12 +60,11 @@ class PlacesCollectionPage extends HookConsumerWidget {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
children: [
|
children: [
|
||||||
if (search.value == null)
|
if (search.value == null) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
width: context.width,
|
|
||||||
child: MapThumbnail(
|
child: MapThumbnail(
|
||||||
onTap: (_, __) => context
|
onTap: (_, __) => context
|
||||||
.pushRoute(MapRoute(initialLocation: currentLocation)),
|
.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(
|
places.when(
|
||||||
data: (places) {
|
data: (places) {
|
||||||
if (search.value != null) {
|
if (search.value != null) {
|
||||||
@ -81,6 +133,26 @@ class PlacesCollectionPage extends HookConsumerWidget {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.contains(search.value!.toLowerCase());
|
.contains(search.value!.toLowerCase());
|
||||||
}).toList();
|
}).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(
|
return ListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
|
@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.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/models/search/search_curated_content.model.dart';
|
||||||
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
|
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/search/explore_grid.dart';
|
import 'package:immich_mobile/widgets/search/explore_grid.dart';
|
||||||
@ -13,8 +14,7 @@ class AllPlacesPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
AsyncValue<List<SearchCuratedContent>> places =
|
AsyncValue<List<PlaceResult>> places = ref.watch(getAllPlacesProvider);
|
||||||
ref.watch(getAllPlacesProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@ -28,7 +28,14 @@ class AllPlacesPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
body: places.widgetWhen(
|
body: places.widgetWhen(
|
||||||
onData: (data) => ExploreGrid(
|
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: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/models/search/search_curated_content.model.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/services/search.service.dart';
|
import 'package:immich_mobile/services/search.service.dart';
|
||||||
@ -29,7 +30,7 @@ final getPreviewPlacesProvider =
|
|||||||
});
|
});
|
||||||
|
|
||||||
final getAllPlacesProvider =
|
final getAllPlacesProvider =
|
||||||
FutureProvider.autoDispose<List<SearchCuratedContent>>((ref) async {
|
FutureProvider.autoDispose<List<PlaceResult>>((ref) async {
|
||||||
final SearchService searchService = ref.watch(searchServiceProvider);
|
final SearchService searchService = ref.watch(searchServiceProvider);
|
||||||
|
|
||||||
final assetPlaces = await searchService.getAllPlaces();
|
final assetPlaces = await searchService.getAllPlaces();
|
||||||
@ -40,9 +41,11 @@ final getAllPlacesProvider =
|
|||||||
|
|
||||||
final curatedContent = assetPlaces
|
final curatedContent = assetPlaces
|
||||||
.map(
|
.map(
|
||||||
(data) => SearchCuratedContent(
|
(data) => PlaceResult(
|
||||||
label: data.exifInfo!.city!,
|
label: data.exifInfo!.city!,
|
||||||
id: data.id,
|
id: data.id,
|
||||||
|
latitude: data.exifInfo!.latitude!.toDouble(),
|
||||||
|
longitude: data.exifInfo!.longitude!.toDouble(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.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