Skip to content

feat(mobile): sort places by distance #17740

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions mobile/lib/models/places/place_result.model.dart
Original file line number Diff line number Diff line change
@@ -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;
}
104 changes: 102 additions & 2 deletions mobile/lib/pages/library/places/places_collection.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,28 @@ 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);
final isAscending = useState(true); // Add state for sort order

return Scaffold(
appBar: AppBar(
Expand All @@ -52,12 +61,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)),
Expand All @@ -73,6 +81,63 @@ class PlacesCollectionPage extends HookConsumerWidget {
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (currentLocation != null) ...[
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(),
style: const TextStyle(
fontSize: 14,
),
),
),
),
],
IconButton(
icon: const Icon(
Icons.swap_vert,
),
onPressed: () {
isAscending.value = !isAscending.value;
},
),
],
),
),
],
places.when(
data: (places) {
if (search.value != null) {
Expand All @@ -81,6 +146,41 @@ class PlacesCollectionPage extends HookConsumerWidget {
.toLowerCase()
.contains(search.value!.toLowerCase());
}).toList();
} else {
// Sort based on the selected filter type
places = List.from(places);

if (filterType.value == FilterType.distance &&
currentLocation != null) {
// Sort places by distance
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 isAscending.value
? distanceA.compareTo(distanceB)
: distanceB.compareTo(distanceA);
Comment on lines +170 to +172
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's generally better to move this outside of the inner sort function and have separate variants to keep the sort function branchless.

});
Comment on lines +156 to +173
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kind of thing is usually indexed. Can you test how long it takes to brute force it like this with maybe 5000 places?

} else {
// Sort places by name
places.sort(
(a, b) => isAscending.value
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

? a.label.toLowerCase().compareTo(b.label.toLowerCase())
: b.label
.toLowerCase()
.compareTo(a.label.toLowerCase()),
);
}
}
return ListView.builder(
shrinkWrap: true,
Expand Down
13 changes: 10 additions & 3 deletions mobile/lib/pages/search/all_places.page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand All @@ -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(),
),
),
);
Expand Down
7 changes: 5 additions & 2 deletions mobile/lib/providers/search/search_page_state.provider.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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(),
Comment on lines +47 to +48
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually safe? These values can be null.

),
)
.toList();
Expand Down
30 changes: 30 additions & 0 deletions mobile/lib/utils/calculate_distance.dart
Original file line number Diff line number Diff line change
@@ -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;
}