Compare commits

..

5 Commits

Author SHA1 Message Date
mertalev
57933af9b0 add env to docs 2025-04-19 17:21:42 -04:00
mertalev
1c4a8c3968 fix tests 2025-04-19 17:06:53 -04:00
mertalev
e2d80755c6 use arena by default in native installation 2025-04-19 16:56:53 -04:00
mertalev
5a3b11d603 add test 2025-04-19 16:31:00 -04:00
mertalev
543bc72ae3 coreml 2025-04-19 16:31:00 -04:00
27 changed files with 206 additions and 547 deletions

View File

@@ -23,32 +23,23 @@ 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 Dumps
### Automatic Database Backups
:::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.
:::
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.
:::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.
:::
#### Trigger Backup
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.
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.
#### Restoring
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.
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.
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 >= 545 (it must support CUDA 12.3).
- The installed driver must be >= 535 (it must support CUDA 12.2).
- On Linux (except for WSL2), you also need to have [NVIDIA Container Toolkit][nvct] installed.
#### ROCm

View File

@@ -148,30 +148,31 @@ Redis (Sentinel) URL example JSON before encoding:
## Machine Learning
| Variable | Description | Default | Containers |
| :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server |
| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
| Variable | Description | Default | Containers |
| :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :--------------------------: | :--------------- |
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `300` | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server |
| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.

View File

@@ -76,7 +76,6 @@ import {
mdiWeb,
mdiDatabaseOutline,
mdiLinkEdit,
mdiTagFaces,
mdiMovieOpenPlayOutline,
} from '@mdi/js';
import Layout from '@theme/Layout';
@@ -84,8 +83,6 @@ 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),
@@ -245,21 +242,6 @@ 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',
@@ -284,8 +266,8 @@ const milestones: Item[] = [
withRelease({
icon: mdiDatabaseOutline,
iconColor: 'brown',
title: 'Automatic database dumps',
description: 'Database dumps are now integrated into the Immich server',
title: 'Automatic database backups',
description: 'Database backups are now integrated into the Immich server',
release: 'v1.120.0',
}),
{
@@ -318,7 +300,7 @@ const milestones: Item[] = [
withRelease({
icon: mdiFolderMultiple,
iconColor: 'brown',
title: 'Folders view',
title: 'Folders',
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": "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.",
"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",
"check_all": "Check All",
"cleanup": "Cleanup",
"cleared_jobs": "Cleared jobs for: {job}",
@@ -758,7 +758,6 @@
"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",
@@ -1711,7 +1710,6 @@
"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

@@ -65,7 +65,8 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
FROM python:3.11-slim-bookworm@sha256:49d73c49616929b0a4f37c50fee0056eb4b0f15de624591e8d9bf84b4dfdd3ce AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.11-slim-bookworm@sha256:49d73c49616929b0a4f37c50fee0056eb4b0f15de624591e8d9bf84b4dfdd3ce AS prod-openvino
@@ -82,7 +83,8 @@ RUN apt-get update && \
FROM nvidia/cuda:12.2.2-runtime-ubuntu22.04@sha256:94c1577b2cd9dd6c0312dc04dff9cb2fdce2b268018abc3d7c2dbcacf1155000 AS prod-cuda
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
RUN apt-get update && \
apt-get install --no-install-recommends -yqq libcudnn9-cuda-12 && \
@@ -98,7 +100,8 @@ FROM rocm/dev-ubuntu-22.04:6.3.4-complete@sha256:1f7e92ca7e3a3785680473329ed1091
FROM prod-cpu AS prod-armnn
ENV LD_LIBRARY_PATH=/opt/armnn \
LD_PRELOAD=/usr/lib/libmimalloc.so.2
LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
RUN apt-get update && apt-get install -y --no-install-recommends ocl-icd-libopencl1 mesa-opencl-icd libgomp1 && \
rm -rf /var/lib/apt/lists/* && \
@@ -118,7 +121,8 @@ COPY --from=builder-armnn \
FROM prod-cpu AS prod-rknn
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
ADD --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/v2.3.0/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so /usr/lib/

View File

@@ -61,6 +61,7 @@ class Settings(BaseSettings):
request_threads: int = os.cpu_count() or 4
model_inter_op_threads: int = 0
model_intra_op_threads: int = 0
model_arena: bool = True
ann: bool = True
ann_fp16_turbo: bool = False
ann_tuning_level: int = 2

View File

@@ -79,6 +79,7 @@ SUPPORTED_PROVIDERS = [
"CUDAExecutionProvider",
"ROCMExecutionProvider",
"OpenVINOExecutionProvider",
"CoreMLExecutionProvider",
"CPUExecutionProvider",
]

View File

@@ -96,6 +96,14 @@ class OrtSession:
"precision": "FP32",
"cache_dir": (self.model_path.parent / "openvino").as_posix(),
}
case "CoreMLExecutionProvider":
options = {
"ModelFormat": "MLProgram",
"MLComputeUnits": "ALL",
"SpecializationStrategy": "FastPrediction",
"AllowLowPrecisionAccumulationOnGPU": "1",
"ModelCacheDirectory": (self.model_path.parent / "coreml").as_posix(),
}
case _:
options = {}
provider_options.append(options)
@@ -115,7 +123,7 @@ class OrtSession:
@property
def _sess_options_default(self) -> ort.SessionOptions:
sess_options = ort.SessionOptions()
sess_options.enable_cpu_mem_arena = False
sess_options.enable_cpu_mem_arena = settings.model_arena
# avoid thread contention between models
if settings.model_inter_op_threads > 0:

View File

@@ -180,6 +180,7 @@ class TestOrtSession:
CUDA_EP_OUT_OF_ORDER = ["CPUExecutionProvider", "CUDAExecutionProvider"]
TRT_EP = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]
ROCM_EP = ["ROCMExecutionProvider", "CPUExecutionProvider"]
COREML_EP = ["CoreMLExecutionProvider", "CPUExecutionProvider"]
@pytest.mark.providers(CPU_EP)
def test_sets_cpu_provider(self, providers: list[str]) -> None:
@@ -225,6 +226,12 @@ class TestOrtSession:
assert session.providers == self.ROCM_EP
@pytest.mark.providers(COREML_EP)
def test_uses_coreml(self, providers: list[str]) -> None:
session = OrtSession("ViT-B-32__openai")
assert session.providers == self.COREML_EP
def test_sets_provider_kwarg(self) -> None:
providers = ["CUDAExecutionProvider"]
session = OrtSession("ViT-B-32__openai", providers=providers)
@@ -284,7 +291,6 @@ class TestOrtSession:
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
assert session.sess_options.inter_op_num_threads == 1
assert session.sess_options.intra_op_num_threads == 2
assert session.sess_options.enable_cpu_mem_arena is False
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
@@ -302,6 +308,26 @@ class TestOrtSession:
assert session.sess_options.inter_op_num_threads == 2
assert session.sess_options.intra_op_num_threads == 4
def test_uses_arena_if_enabled(self, mocker: MockerFixture) -> None:
mock_settings = mocker.patch("immich_ml.sessions.ort.settings", autospec=True)
mock_settings.model_inter_op_threads = 0
mock_settings.model_intra_op_threads = 0
mock_settings.model_arena = True
session = OrtSession("ViT-B-32__openai", providers=["CPUExecutionProvider"])
assert session.sess_options.enable_cpu_mem_arena
def test_does_not_use_arena_if_disabled(self, mocker: MockerFixture) -> None:
mock_settings = mocker.patch("immich_ml.sessions.ort.settings", autospec=True)
mock_settings.model_inter_op_threads = 0
mock_settings.model_intra_op_threads = 0
mock_settings.model_arena = False
session = OrtSession("ViT-B-32__openai", providers=["CPUExecutionProvider"])
assert not session.sess_options.enable_cpu_mem_arena
def test_sets_sess_options_kwarg(self) -> None:
sess_options = ort.SessionOptions()
session = OrtSession(

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
platform :ios, '14.0'
# platform :ios, '12.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'] = '14.0'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
end
end
end

View File

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

View File

@@ -546,7 +546,7 @@
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.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 = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.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 = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@@ -1,77 +0,0 @@
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,7 +1,6 @@
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';
@@ -13,7 +12,6 @@ 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';
@@ -299,35 +297,33 @@ class LocalAlbumsCollectionCard extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
Container(
height: size,
width: size,
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(),
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: 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(
padding: const EdgeInsets.all(8.0),
@@ -357,66 +353,43 @@ class PlacesCollectionCard extends StatelessWidget {
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
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,
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,
),
),
),
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,
),
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,28 +13,18 @@ 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;
const PlacesCollectionPage({super.key});
@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(
@@ -61,83 +51,25 @@ 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)),
onTap: (_, __) => context.pushRoute(const MapRoute()),
zoom: 8,
centre: currentLocation ??
const LatLng(
21.44950,
-157.91959,
),
centre: 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) {
@@ -146,41 +78,6 @@ 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,7 +3,6 @@ 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';
@@ -14,7 +13,8 @@ class AllPlacesPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<List<PlaceResult>> places = ref.watch(getAllPlacesProvider);
AsyncValue<List<SearchCuratedContent>> places =
ref.watch(getAllPlacesProvider);
return Scaffold(
appBar: AppBar(
@@ -28,14 +28,7 @@ class AllPlacesPage extends HookConsumerWidget {
),
body: places.widgetWhen(
onData: (data) => ExploreGrid(
curatedContent: data
.map(
(e) => SearchCuratedContent(
label: e.label,
id: e.id,
),
)
.toList(),
curatedContent: data,
),
),
);

View File

@@ -34,8 +34,7 @@ import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class MapPage extends HookConsumerWidget {
const MapPage({super.key, this.initialLocation});
final LatLng? initialLocation;
const MapPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -236,8 +235,7 @@ class MapPage extends HookConsumerWidget {
}
void onZoomToLocation() async {
final (location, error) =
await MapUtils.checkPermAndGetLocation(context: context);
final (location, error) = await MapUtils.checkPermAndGetLocation(context);
if (error != null) {
if (error == LocationPermission.unableToDetermine && context.mounted) {
ImmichToast.show(
@@ -274,7 +272,6 @@ class MapPage extends HookConsumerWidget {
body: Stack(
children: [
_MapWithMarker(
initialLocation: initialLocation,
style: style,
selectedMarker: selectedMarker,
onMapCreated: onMapCreated,
@@ -306,7 +303,6 @@ class MapPage extends HookConsumerWidget {
body: Stack(
children: [
_MapWithMarker(
initialLocation: initialLocation,
style: style,
selectedMarker: selectedMarker,
onMapCreated: onMapCreated,
@@ -372,7 +368,6 @@ class _MapWithMarker extends StatelessWidget {
final OnStyleLoadedCallback onStyleLoaded;
final Function()? onMarkerTapped;
final ValueNotifier<_AssetMarkerMeta?> selectedMarker;
final LatLng? initialLocation;
const _MapWithMarker({
required this.style,
@@ -382,7 +377,6 @@ class _MapWithMarker extends StatelessWidget {
required this.onStyleLoaded,
required this.selectedMarker,
this.onMarkerTapped,
this.initialLocation,
});
@override
@@ -395,10 +389,8 @@ class _MapWithMarker extends StatelessWidget {
children: [
style.widgetWhen(
onData: (style) => MapLibreMap(
initialCameraPosition: CameraPosition(
target: initialLocation ?? const LatLng(0, 0),
zoom: initialLocation != null ? 12 : 0,
),
initialCameraPosition:
const CameraPosition(target: LatLng(0, 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: context);
await MapUtils.checkPermAndGetLocation(context);
if (currentLocation == null) {
return;

View File

@@ -1,5 +1,4 @@
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';
@@ -30,7 +29,7 @@ final getPreviewPlacesProvider =
});
final getAllPlacesProvider =
FutureProvider.autoDispose<List<PlaceResult>>((ref) async {
FutureProvider.autoDispose<List<SearchCuratedContent>>((ref) async {
final SearchService searchService = ref.watch(searchServiceProvider);
final assetPlaces = await searchService.getAllPlaces();
@@ -41,11 +40,9 @@ final getAllPlacesProvider =
final curatedContent = assetPlaces
.map(
(data) => PlaceResult(
(data) => SearchCuratedContent(
label: data.exifInfo!.city!,
id: data.id,
latitude: data.exifInfo!.latitude!.toDouble(),
longitude: data.exifInfo!.longitude!.toDouble(),
),
)
.toList();

View File

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

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

View File

@@ -46,39 +46,12 @@ 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) {
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;
assetsInBounds.value = await ref
.read(dbProvider)
.assets
.getAllByRemoteId(event.assetRemoteIds);
return;
}
}
@@ -151,7 +124,7 @@ class MapAssetGrid extends HookConsumerWidget {
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
// Place it just below the drag handle
heightFactor: 0.87,
heightFactor: 0.80,
child: assetsInBounds.value.isNotEmpty
? ref
.watch(assetsTimelineProvider(assetsInBounds.value))
@@ -278,18 +251,8 @@ class _MapSheetDragRegion extends StatelessWidget {
const SizedBox(height: 15),
const CustomDraggingHandle(),
const SizedBox(height: 15),
Center(
child: Text(
assetsInBoundsText,
style: TextStyle(
fontSize: 20,
color: context.textTheme.displayLarge?.color
?.withValues(alpha: 0.75),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 8),
Text(assetsInBoundsText, style: context.textTheme.bodyLarge),
const Divider(height: 35),
],
),
ValueListenableBuilder(
@@ -297,14 +260,14 @@ class _MapSheetDragRegion extends StatelessWidget {
builder: (_, value, __) => Visibility(
visible: value != null,
child: Positioned(
right: 18,
top: 24,
right: 15,
top: 15,
child: IconButton(
icon: Icon(
Icons.map_outlined,
color: context.textTheme.displayLarge?.color,
),
iconSize: 24,
iconSize: 20,
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(MapRoute());
context.pushRoute(const MapRoute());
},
child: IgnorePointer(
child: MapThumbnail(

View File

@@ -696,18 +696,18 @@ packages:
dependency: "direct main"
description:
name: geolocator
sha256: e7ebfa04ce451daf39b5499108c973189a71a919aa53c1204effda1c5b93b822
sha256: "6cb9fb6e5928b58b9a84bdf85012d757fd07aab8215c5205337021c4999bad27"
url: "https://pub.dev"
source: hosted
version: "14.0.0"
version: "11.1.0"
geolocator_android:
dependency: transitive
description:
name: geolocator_android
sha256: "114072db5d1dce0ec0b36af2697f55c133bc89a2c8dd513e137c0afe59696ed4"
sha256: "7aefc530db47d90d0580b552df3242440a10fe60814496a979aa67aa98b1fd47"
url: "https://pub.dev"
source: hosted
version: "5.0.1+1"
version: "4.6.1"
geolocator_apple:
dependency: transitive
description:
@@ -728,10 +728,10 @@ packages:
dependency: transitive
description:
name: geolocator_web
sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
url: "https://pub.dev"
source: hosted
version: "4.1.3"
version: "3.0.0"
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: ^14.0.0
geolocator: ^11.0.0
hooks_riverpod: ^2.6.1
http: ^1.3.0
image_picker: ^1.1.2