Compare commits

...

7 Commits

Author SHA1 Message Date
Yaros
92ab0d4c82 feat: add sort order 2025-04-21 18:40:53 +02:00
Yaros
584a609966 chore: small style tweak 2025-04-21 18:09:20 +02:00
Yaros
ba9e37bad2 feat: sort places by distance 2025-04-21 12:03:28 +02:00
Yaros
f0ff8581da feat(mobile): map improvements (#17714)
* fix: remove unnecessary db operations in map

* feat: use user's location for map thumbnails

* chore: refactored handleMapEvents

* fix: location fails fetching & update geolocator

* chore: minor refactor

* chore: small style tweak

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-04-21 05:55:13 +00:00
Yaros
c49fd2065b chore(mobile): bump ios deployment target (#17715)
* chore: bump ios deployment target

* podfile

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-04-21 05:18:25 +00:00
aviv926
21a6eb30ff feat(docs): documentation update (#17720)
Documentation update
2025-04-20 23:55:58 -05:00
Matthew Momjian
9e063c993c fix(docs): Database dump warnings (#17676)
* docs

* admin page

* roadmap

* whitespace

* whitespace

* no danger
2025-04-20 23:54:37 -05:00
21 changed files with 520 additions and 138 deletions

View File

@@ -23,23 +23,32 @@ Refer to the official [postgres documentation](https://www.postgresql.org/docs/c
It is not recommended to directly backup the `DB_DATA_LOCATION` folder. Doing so while the database is running can lead to a corrupted backup that cannot be restored.
:::
### Automatic Database Backups
### Automatic Database Dumps
For convenience, Immich will automatically create database backups by default. The backups are stored in `UPLOAD_LOCATION/backups`.
As mentioned above, you should make your own backup of these together with the asset folders as noted below.
You can adjust the schedule and amount of kept backups in the [admin settings](http://my.immich.app/admin/system-settings?isOpen=backup).
By default, Immich will keep the last 14 backups and create a new backup every day at 2:00 AM.
:::warning
The automatic database dumps can be used to restore the database in the event of damage to the Postgres database files.
There is no monitoring for these dumps and you will not be notified if they are unsuccessful.
:::
#### Trigger Backup
:::caution
The database dumps do **NOT** contain any pictures or videos, only metadata. They are only usable with a copy of the other files in `UPLOAD_LOCATION` as outlined below.
:::
You are able to trigger a backup in the [admin job status page](http://my.immich.app/admin/jobs-status).
Visit the page, open the "Create job" modal from the top right, select "Backup Database" and click "Confirm".
A job will run and trigger a backup, you can verify this worked correctly by checking the logs or the backup folder.
This backup will count towards the last X backups that will be kept based on your settings.
For disaster-recovery purposes, Immich will automatically create database dumps. The dumps are stored in `UPLOAD_LOCATION/backups`.
Please be sure to make your own, independent backup of the database together with the asset folders as noted below.
You can adjust the schedule and amount of kept database dumps in the [admin settings](http://my.immich.app/admin/system-settings?isOpen=backup).
By default, Immich will keep the last 14 database dumps and create a new dump every day at 2:00 AM.
#### Trigger Dump
You are able to trigger a database dump in the [admin job status page](http://my.immich.app/admin/jobs-status).
Visit the page, open the "Create job" modal from the top right, select "Create Database Dump" and click "Confirm".
A job will run and trigger a dump, you can verify this worked correctly by checking the logs or the `backups/` folder.
This dumps will count towards the last `X` dumps that will be kept based on your settings.
#### Restoring
We hope to make restoring simpler in future versions, for now you can find the backups in the `UPLOAD_LOCATION/backups` folder on your host.
We hope to make restoring simpler in future versions, for now you can find the database dumps in the `UPLOAD_LOCATION/backups` folder on your host.
Then please follow the steps in the following section for restoring the database.
### Manual Backup and Restore

View File

@@ -42,7 +42,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
- The GPU must have compute capability 5.2 or greater.
- The server must have the official NVIDIA driver installed.
- The installed driver must be >= 535 (it must support CUDA 12.2).
- The installed driver must be >= 545 (it must support CUDA 12.3).
- On Linux (except for WSL2), you also need to have [NVIDIA Container Toolkit][nvct] installed.
#### ROCm

View File

@@ -76,6 +76,7 @@ import {
mdiWeb,
mdiDatabaseOutline,
mdiLinkEdit,
mdiTagFaces,
mdiMovieOpenPlayOutline,
} from '@mdi/js';
import Layout from '@theme/Layout';
@@ -83,6 +84,8 @@ import React from 'react';
import { Item, Timeline } from '../components/timeline';
const releases = {
'v1.130.0': new Date(2025, 2, 25),
'v1.127.0': new Date(2025, 1, 26),
'v1.122.0': new Date(2024, 11, 5),
'v1.120.0': new Date(2024, 10, 6),
'v1.114.0': new Date(2024, 8, 6),
@@ -242,6 +245,21 @@ const roadmap: Item[] = [
];
const milestones: Item[] = [
withRelease({
icon: mdiFolderMultiple,
iconColor: 'brown',
title: 'Folders view in the mobile app',
description: 'Browse your photos and videos in their folder structure inside the mobile app',
release: 'v1.130.0',
}),
withRelease({
icon: mdiTagFaces,
iconColor: 'teal',
title: 'Manual face tagging',
description:
'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.',
release: 'v1.127.0',
}),
{
icon: mdiStar,
iconColor: 'gold',
@@ -266,8 +284,8 @@ const milestones: Item[] = [
withRelease({
icon: mdiDatabaseOutline,
iconColor: 'brown',
title: 'Automatic database backups',
description: 'Database backups are now integrated into the Immich server',
title: 'Automatic database dumps',
description: 'Database dumps are now integrated into the Immich server',
release: 'v1.120.0',
}),
{
@@ -300,7 +318,7 @@ const milestones: Item[] = [
withRelease({
icon: mdiFolderMultiple,
iconColor: 'brown',
title: 'Folders',
title: 'Folders view',
description: 'Browse your photos and videos in their folder structure',
release: 'v1.113.0',
}),

View File

@@ -39,11 +39,11 @@
"authentication_settings_disable_all": "Are you sure you want to disable all login methods? Login will be completely disabled.",
"authentication_settings_reenable": "To re-enable, use a <link>Server Command</link>.",
"background_task_job": "Background Tasks",
"backup_database": "Backup Database",
"backup_database_enable_description": "Enable database backups",
"backup_keep_last_amount": "Amount of previous backups to keep",
"backup_settings": "Backup Settings",
"backup_settings_description": "Manage database backup settings",
"backup_database": "Create Database Dump",
"backup_database_enable_description": "Enable database dumps",
"backup_keep_last_amount": "Amount of previous dumps to keep",
"backup_settings": "Database Dump Settings",
"backup_settings_description": "Manage database dump settings. Note: These jobs are not monitored and you will not be notified of failure.",
"check_all": "Check All",
"cleanup": "Cleanup",
"cleared_jobs": "Cleared jobs for: {job}",
@@ -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

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
platform :ios, '14.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -45,7 +45,7 @@ post_install do |installer|
installer.generated_projects.each do |project|
project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
end
end
end

View File

@@ -224,7 +224,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
background_downloader: b42a56120f5348bff70e74222f0e9e6f7f1a1537
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
@@ -261,6 +261,6 @@ SPEC CHECKSUMS:
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
PODFILE CHECKSUM: 03b7eead4ee77b9e778179eeb0f3b5513617451c
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45
COCOAPODS: 1.16.2

View File

@@ -546,7 +546,7 @@
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -690,7 +690,7 @@
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -720,7 +720,7 @@
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

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

@@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
@@ -12,6 +13,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart';
@@ -297,32 +299,34 @@ class LocalAlbumsCollectionCard extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
SizedBox(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(30),
context.colorScheme.primary.withAlpha(25),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(30),
context.colorScheme.primary.withAlpha(25),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: albums.take(4).map((album) {
return AlbumThumbnailCard(
album: album,
showTitle: false,
);
}).toList(),
),
),
child: GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: albums.take(4).map((album) {
return AlbumThumbnailCard(
album: album,
showTitle: false,
);
}).toList(),
),
),
Padding(
@@ -353,43 +357,66 @@ class PlacesCollectionCard extends StatelessWidget {
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const PlacesCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: context.colorScheme.secondaryContainer.withAlpha(100),
),
child: IgnorePointer(
child: MapThumbnail(
zoom: 8,
centre: const LatLng(
21.44950,
-157.91959,
),
showAttribution: false,
themeMode:
context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'places'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
return FutureBuilder<(Position?, LocationPermission?)>(
future: MapUtils.checkPermAndGetLocation(
context: context,
silent: true,
),
builder: (context, snapshot) {
var position = snapshot.data?.$1;
return GestureDetector(
onTap: () => context.pushRoute(
PlacesCollectionRoute(
currentLocation: position != null
? LatLng(position.latitude, position.longitude)
: null,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: size,
width: size,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(20)),
color: context.colorScheme.secondaryContainer
.withAlpha(100),
),
child: IgnorePointer(
child: snapshot.connectionState ==
ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: MapThumbnail(
zoom: 8,
centre: LatLng(
position?.latitude ?? 21.44950,
position?.longitude ?? -157.91959,
),
showAttribution: false,
themeMode: context.isDarkTheme
? ThemeMode.dark
: ThemeMode.light,
),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'places'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
},
);

View File

@@ -13,18 +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});
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(
@@ -51,25 +61,83 @@ 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(const MapRoute()),
onTap: (_, __) => context
.pushRoute(MapRoute(initialLocation: currentLocation)),
zoom: 8,
centre: const LatLng(
21.44950,
-157.91959,
),
centre: currentLocation ??
const LatLng(
21.44950,
-157.91959,
),
showAttribution: false,
themeMode:
context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
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) {
@@ -78,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);
});
} else {
// Sort places by name
places.sort(
(a, b) => isAscending.value
? a.label.toLowerCase().compareTo(b.label.toLowerCase())
: b.label
.toLowerCase()
.compareTo(a.label.toLowerCase()),
);
}
}
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

@@ -34,7 +34,8 @@ import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class MapPage extends HookConsumerWidget {
const MapPage({super.key});
const MapPage({super.key, this.initialLocation});
final LatLng? initialLocation;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -235,7 +236,8 @@ class MapPage extends HookConsumerWidget {
}
void onZoomToLocation() async {
final (location, error) = await MapUtils.checkPermAndGetLocation(context);
final (location, error) =
await MapUtils.checkPermAndGetLocation(context: context);
if (error != null) {
if (error == LocationPermission.unableToDetermine && context.mounted) {
ImmichToast.show(
@@ -272,6 +274,7 @@ class MapPage extends HookConsumerWidget {
body: Stack(
children: [
_MapWithMarker(
initialLocation: initialLocation,
style: style,
selectedMarker: selectedMarker,
onMapCreated: onMapCreated,
@@ -303,6 +306,7 @@ class MapPage extends HookConsumerWidget {
body: Stack(
children: [
_MapWithMarker(
initialLocation: initialLocation,
style: style,
selectedMarker: selectedMarker,
onMapCreated: onMapCreated,
@@ -368,6 +372,7 @@ class _MapWithMarker extends StatelessWidget {
final OnStyleLoadedCallback onStyleLoaded;
final Function()? onMarkerTapped;
final ValueNotifier<_AssetMarkerMeta?> selectedMarker;
final LatLng? initialLocation;
const _MapWithMarker({
required this.style,
@@ -377,6 +382,7 @@ class _MapWithMarker extends StatelessWidget {
required this.onStyleLoaded,
required this.selectedMarker,
this.onMarkerTapped,
this.initialLocation,
});
@override
@@ -389,8 +395,10 @@ class _MapWithMarker extends StatelessWidget {
children: [
style.widgetWhen(
onData: (style) => MapLibreMap(
initialCameraPosition:
const CameraPosition(target: LatLng(0, 0)),
initialCameraPosition: CameraPosition(
target: initialLocation ?? const LatLng(0, 0),
zoom: initialLocation != null ? 12 : 0,
),
styleString: style,
// This is needed to update the selectedMarker's position on map camera updates
// The changes are notified through the mapController ValueListener which is added in [onMapCreated]

View File

@@ -46,7 +46,7 @@ class MapLocationPickerPage extends HookConsumerWidget {
Future<void> getCurrentLocation() async {
var (currentLocation, _) =
await MapUtils.checkPermAndGetLocation(context);
await MapUtils.checkPermAndGetLocation(context: context);
if (currentLocation == null) {
return;

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

@@ -1024,10 +1024,17 @@ class MapLocationPickerRouteArgs {
/// generated route for
/// [MapPage]
class MapRoute extends PageRouteInfo<void> {
const MapRoute({List<PageRouteInfo>? children})
: super(
class MapRoute extends PageRouteInfo<MapRouteArgs> {
MapRoute({
Key? key,
LatLng? initialLocation,
List<PageRouteInfo>? children,
}) : super(
MapRoute.name,
args: MapRouteArgs(
key: key,
initialLocation: initialLocation,
),
initialChildren: children,
);
@@ -1036,11 +1043,32 @@ class MapRoute extends PageRouteInfo<void> {
static PageInfo page = PageInfo(
name,
builder: (data) {
return const MapPage();
final args =
data.argsAs<MapRouteArgs>(orElse: () => const MapRouteArgs());
return MapPage(
key: args.key,
initialLocation: args.initialLocation,
);
},
);
}
class MapRouteArgs {
const MapRouteArgs({
this.key,
this.initialLocation,
});
final Key? key;
final LatLng? initialLocation;
@override
String toString() {
return 'MapRouteArgs{key: $key, initialLocation: $initialLocation}';
}
}
/// generated route for
/// [MemoryPage]
class MemoryRoute extends PageRouteInfo<MemoryRouteArgs> {
@@ -1333,10 +1361,17 @@ class PhotosRoute extends PageRouteInfo<void> {
/// generated route for
/// [PlacesCollectionPage]
class PlacesCollectionRoute extends PageRouteInfo<void> {
const PlacesCollectionRoute({List<PageRouteInfo>? children})
: super(
class PlacesCollectionRoute extends PageRouteInfo<PlacesCollectionRouteArgs> {
PlacesCollectionRoute({
Key? key,
LatLng? currentLocation,
List<PageRouteInfo>? children,
}) : super(
PlacesCollectionRoute.name,
args: PlacesCollectionRouteArgs(
key: key,
currentLocation: currentLocation,
),
initialChildren: children,
);
@@ -1345,11 +1380,32 @@ class PlacesCollectionRoute extends PageRouteInfo<void> {
static PageInfo page = PageInfo(
name,
builder: (data) {
return const PlacesCollectionPage();
final args = data.argsAs<PlacesCollectionRouteArgs>(
orElse: () => const PlacesCollectionRouteArgs());
return PlacesCollectionPage(
key: args.key,
currentLocation: args.currentLocation,
);
},
);
}
class PlacesCollectionRouteArgs {
const PlacesCollectionRouteArgs({
this.key,
this.currentLocation,
});
final Key? key;
final LatLng? currentLocation;
@override
String toString() {
return 'PlacesCollectionRouteArgs{key: $key, currentLocation: $currentLocation}';
}
}
/// generated route for
/// [RecentlyAddedPage]
class RecentlyAddedRoute extends PageRouteInfo<void> {

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

View File

@@ -64,12 +64,13 @@ class MapUtils {
'features': markers.map(_addFeature).toList(),
};
static Future<(Position?, LocationPermission?)> checkPermAndGetLocation(
BuildContext context,
) async {
static Future<(Position?, LocationPermission?)> checkPermAndGetLocation({
required BuildContext context,
bool silent = false,
}) async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
if (!serviceEnabled && !silent) {
showDialog(
context: context,
builder: (context) => _LocationServiceDisabledDialog(),
@@ -80,7 +81,7 @@ class MapUtils {
LocationPermission permission = await Geolocator.checkPermission();
bool shouldRequestPermission = false;
if (permission == LocationPermission.denied) {
if (permission == LocationPermission.denied && !silent) {
shouldRequestPermission = await showDialog(
context: context,
builder: (context) => _LocationPermissionDisabledDialog(),
@@ -94,15 +95,19 @@ class MapUtils {
permission == LocationPermission.deniedForever) {
// Open app settings only if you did not request for permission before
if (permission == LocationPermission.deniedForever &&
!shouldRequestPermission) {
!shouldRequestPermission &&
!silent) {
await Geolocator.openAppSettings();
}
return (null, LocationPermission.deniedForever);
}
Position currentUserLocation = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.medium,
timeLimit: const Duration(seconds: 5),
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 0,
timeLimit: Duration(seconds: 5),
),
);
return (currentUserLocation, null);
} catch (error, stack) {

View File

@@ -46,12 +46,39 @@ class MapAssetGrid extends HookConsumerWidget {
final gridScrollThrottler =
useThrottler(interval: const Duration(milliseconds: 300));
// Add a cache for assets we've already loaded
final assetCache = useRef<Map<String, Asset>>({});
void handleMapEvents(MapEvent event) async {
if (event is MapAssetsInBoundsUpdated) {
assetsInBounds.value = await ref
.read(dbProvider)
.assets
.getAllByRemoteId(event.assetRemoteIds);
final assetIds = event.assetRemoteIds;
final missingIds = <String>[];
final currentAssets = <Asset>[];
for (final id in assetIds) {
final asset = assetCache.value[id];
if (asset != null) {
currentAssets.add(asset);
} else {
missingIds.add(id);
}
}
// Only fetch missing assets
if (missingIds.isNotEmpty) {
final newAssets =
await ref.read(dbProvider).assets.getAllByRemoteId(missingIds);
// Add new assets to cache and current list
for (final asset in newAssets) {
if (asset.remoteId != null) {
assetCache.value[asset.remoteId!] = asset;
currentAssets.add(asset);
}
}
}
assetsInBounds.value = currentAssets;
return;
}
}
@@ -124,7 +151,7 @@ class MapAssetGrid extends HookConsumerWidget {
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
// Place it just below the drag handle
heightFactor: 0.80,
heightFactor: 0.87,
child: assetsInBounds.value.isNotEmpty
? ref
.watch(assetsTimelineProvider(assetsInBounds.value))
@@ -251,8 +278,18 @@ class _MapSheetDragRegion extends StatelessWidget {
const SizedBox(height: 15),
const CustomDraggingHandle(),
const SizedBox(height: 15),
Text(assetsInBoundsText, style: context.textTheme.bodyLarge),
const Divider(height: 35),
Center(
child: Text(
assetsInBoundsText,
style: TextStyle(
fontSize: 20,
color: context.textTheme.displayLarge?.color
?.withValues(alpha: 0.75),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 8),
],
),
ValueListenableBuilder(
@@ -260,14 +297,14 @@ class _MapSheetDragRegion extends StatelessWidget {
builder: (_, value, __) => Visibility(
visible: value != null,
child: Positioned(
right: 15,
top: 15,
right: 18,
top: 24,
child: IconButton(
icon: Icon(
Icons.map_outlined,
color: context.textTheme.displayLarge?.color,
),
iconSize: 20,
iconSize: 24,
tooltip: 'Zoom to bounds',
onPressed: () => onZoomToAsset?.call(value!),
),

View File

@@ -20,7 +20,7 @@ class SearchMapThumbnail extends StatelessWidget {
return ThumbnailWithInfoContainer(
label: 'search_page_your_map'.tr(),
onTap: () {
context.pushRoute(const MapRoute());
context.pushRoute(MapRoute());
},
child: IgnorePointer(
child: MapThumbnail(

View File

@@ -696,18 +696,18 @@ packages:
dependency: "direct main"
description:
name: geolocator
sha256: "6cb9fb6e5928b58b9a84bdf85012d757fd07aab8215c5205337021c4999bad27"
sha256: e7ebfa04ce451daf39b5499108c973189a71a919aa53c1204effda1c5b93b822
url: "https://pub.dev"
source: hosted
version: "11.1.0"
version: "14.0.0"
geolocator_android:
dependency: transitive
description:
name: geolocator_android
sha256: "7aefc530db47d90d0580b552df3242440a10fe60814496a979aa67aa98b1fd47"
sha256: "114072db5d1dce0ec0b36af2697f55c133bc89a2c8dd513e137c0afe59696ed4"
url: "https://pub.dev"
source: hosted
version: "4.6.1"
version: "5.0.1+1"
geolocator_apple:
dependency: transitive
description:
@@ -728,10 +728,10 @@ packages:
dependency: transitive
description:
name: geolocator_web
sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "4.1.3"
geolocator_windows:
dependency: transitive
description:

View File

@@ -35,7 +35,7 @@ dependencies:
flutter_udid: ^3.0.0
flutter_web_auth_2: ^5.0.0-alpha.0
fluttertoast: ^8.2.12
geolocator: ^11.0.0
geolocator: ^14.0.0
hooks_riverpod: ^2.6.1
http: ^1.3.0
image_picker: ^1.1.2