martabal hai 1 ano
pai
achega
8b7a4f2169
Modificáronse 100 ficheiros con 4228 adicións e 753 borrados
  1. 1 1
      .github/workflows/build-mobile.yml
  2. 2 2
      .github/workflows/docker.yml
  3. 1 1
      .github/workflows/static_analysis.yml
  4. 2 1
      .github/workflows/test.yml
  5. 615 104
      cli/src/api/open-api/api.ts
  6. 1 1
      cli/src/api/open-api/base.ts
  7. 1 1
      cli/src/api/open-api/common.ts
  8. 1 1
      cli/src/api/open-api/configuration.ts
  9. 1 1
      cli/src/api/open-api/index.ts
  10. 2 2
      cli/src/cli/base-command.ts
  11. 2 2
      docker/docker-compose.dev.yml
  12. 2 2
      docker/docker-compose.prod.yml
  13. 2 0
      docker/docker-compose.yml
  14. 2 2
      docs/docs/FAQ.md
  15. 91 0
      docs/docs/install/config-file.md
  16. 0 1
      docs/docs/install/docker-compose.md
  17. 13 7
      docs/docs/install/environment-variables.md
  18. 1 1
      docs/docs/install/post-install.mdx
  19. 2 1
      machine-learning/Dockerfile
  20. 11 4
      machine-learning/app/config.py
  21. 29 14
      machine-learning/app/main.py
  22. 1 1
      machine-learning/app/models/__init__.py
  23. 37 2
      machine-learning/app/models/base.py
  24. 1 1
      machine-learning/app/models/cache.py
  25. 127 17
      machine-learning/app/models/clip.py
  26. 21 4
      machine-learning/app/models/facial_recognition.py
  27. 28 7
      machine-learning/app/models/image_classification.py
  28. 34 19
      machine-learning/app/test_main.py
  29. 710 168
      machine-learning/poetry.lock
  30. 20 5
      machine-learning/pyproject.toml
  31. 2 0
      machine-learning/requirements.txt
  32. 1 1
      mobile/.fvm/fvm_config.json
  33. 1 1
      mobile/android/app/build.gradle
  34. 2 1
      mobile/android/app/src/main/AndroidManifest.xml
  35. 2 2
      mobile/android/fastlane/Fastfile
  36. 18 2
      mobile/assets/i18n/en-US.json
  37. BIN=BIN
      mobile/assets/lighthouse.png
  38. 19 13
      mobile/ios/Podfile.lock
  39. 1 1
      mobile/ios/Runner.xcodeproj/project.pbxproj
  40. 1 1
      mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  41. 0 2
      mobile/ios/Runner/Info.plist
  42. 1 1
      mobile/ios/ci_scripts/ci_post_clone.sh
  43. 1 1
      mobile/ios/fastlane/Fastfile
  44. 10 2
      mobile/lib/main.dart
  45. 10 0
      mobile/lib/modules/album/providers/shared_album.provider.dart
  46. 20 0
      mobile/lib/modules/album/services/album.service.dart
  47. 3 3
      mobile/lib/modules/album/ui/album_thumbnail_listtile.dart
  48. 5 0
      mobile/lib/modules/album/ui/album_title_text_field.dart
  49. 98 32
      mobile/lib/modules/album/ui/album_viewer_appbar.dart
  50. 5 0
      mobile/lib/modules/album/ui/album_viewer_editable_title.dart
  51. 205 0
      mobile/lib/modules/album/views/album_options_part.dart
  52. 32 31
      mobile/lib/modules/album/views/album_viewer_page.dart
  53. 5 2
      mobile/lib/modules/album/views/asset_selection_page.dart
  54. 4 2
      mobile/lib/modules/album/views/create_album_page.dart
  55. 2 2
      mobile/lib/modules/album/views/library_page.dart
  56. 5 6
      mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart
  57. 5 6
      mobile/lib/modules/album/views/select_user_for_sharing_page.dart
  58. 1 1
      mobile/lib/modules/album/views/sharing_page.dart
  59. 9 23
      mobile/lib/modules/archive/views/archive_page.dart
  60. 62 74
      mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart
  61. 1 1
      mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
  62. 7 8
      mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
  63. 1 1
      mobile/lib/modules/backup/background_service/background.service.dart
  64. 1 1
      mobile/lib/modules/backup/providers/backup.provider.dart
  65. 29 18
      mobile/lib/modules/backup/providers/manual_upload.provider.dart
  66. 15 3
      mobile/lib/modules/backup/services/backup.service.dart
  67. 2 2
      mobile/lib/modules/backup/ui/album_info_card.dart
  68. 1 1
      mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart
  69. 2 2
      mobile/lib/modules/backup/views/backup_album_selection_page.dart
  70. 7 7
      mobile/lib/modules/backup/views/backup_controller_page.dart
  71. 1 1
      mobile/lib/modules/backup/views/failed_backup_status_page.dart
  72. 8 15
      mobile/lib/modules/favorite/views/favorites_page.dart
  73. 1 1
      mobile/lib/modules/home/ui/asset_grid/group_divider_title.dart
  74. 7 1
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
  75. 17 4
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart
  76. 1 1
      mobile/lib/modules/home/ui/control_bottom_app_bar.dart
  77. 6 4
      mobile/lib/modules/home/ui/home_page_app_bar.dart
  78. 1 1
      mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart
  79. 11 8
      mobile/lib/modules/home/ui/profile_drawer/profile_drawer_header.dart
  80. 0 44
      mobile/lib/modules/home/ui/user_circle_avatar.dart
  81. 7 38
      mobile/lib/modules/home/views/home_page.dart
  82. 1 1
      mobile/lib/modules/login/ui/change_password_form.dart
  83. 40 0
      mobile/lib/modules/map/models/map_page_event.model.dart
  84. 45 0
      mobile/lib/modules/map/models/map_state.model.dart
  85. 58 0
      mobile/lib/modules/map/providers/map_marker.provider.dart
  86. 51 0
      mobile/lib/modules/map/providers/map_state.provider.dart
  87. 62 0
      mobile/lib/modules/map/services/map.service.dart
  88. 144 0
      mobile/lib/modules/map/ui/asset_marker_icon.dart
  89. 30 0
      mobile/lib/modules/map/ui/location_dialog.dart
  90. 138 0
      mobile/lib/modules/map/ui/map_page_app_bar.dart
  91. 356 0
      mobile/lib/modules/map/ui/map_page_bottom_sheet.dart
  92. 193 0
      mobile/lib/modules/map/ui/map_settings_dialog.dart
  93. 76 0
      mobile/lib/modules/map/ui/map_thumbnail.dart
  94. 499 0
      mobile/lib/modules/map/views/map_page.dart
  95. 1 1
      mobile/lib/modules/memories/ui/memory_card.dart
  96. 2 1
      mobile/lib/modules/onboarding/views/permission_onboarding_page.dart
  97. 2 2
      mobile/lib/modules/partner/views/partner_page.dart
  98. 2 2
      mobile/lib/modules/search/ui/curated_people_row.dart
  99. 110 0
      mobile/lib/modules/search/ui/curated_places_row.dart
  100. 1 1
      mobile/lib/modules/search/ui/search_suggestion_list.dart

+ 1 - 1
.github/workflows/build-mobile.yml

@@ -45,7 +45,7 @@ jobs:
         uses: subosito/flutter-action@v2
         with:
           channel: "stable"
-          flutter-version: "3.10.5"
+          flutter-version: "3.13.0"
           cache: true
 
       - name: Create the Keystore

+ 2 - 2
.github/workflows/docker.yml

@@ -42,7 +42,7 @@ jobs:
         uses: docker/setup-qemu-action@v2.2.0
 
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v2.9.1
+        uses: docker/setup-buildx-action@v2.10.0
         # Workaround to fix error:
         # failed to push: failed to copy: io: read/write on closed pipe
         # See https://github.com/docker/build-push-action/issues/761
@@ -126,7 +126,7 @@ jobs:
         uses: docker/setup-qemu-action@v2.2.0
 
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v2.9.1
+        uses: docker/setup-buildx-action@v2.10.0
         # Workaround to fix error:
         # failed to push: failed to copy: io: read/write on closed pipe
         # See https://github.com/docker/build-push-action/issues/761

+ 1 - 1
.github/workflows/static_analysis.yml

@@ -23,7 +23,7 @@ jobs:
         uses: subosito/flutter-action@v2
         with:
           channel: "stable"
-          flutter-version: "3.10.5"
+          flutter-version: "3.13.0"
 
       - name: Install dependencies
         run: dart pub get

+ 2 - 1
.github/workflows/test.yml

@@ -149,7 +149,7 @@ jobs:
         uses: subosito/flutter-action@v2
         with:
           channel: "stable"
-          flutter-version: "3.10.5"
+          flutter-version: "3.13.0"
       - name: Run tests
         working-directory: ./mobile
         run: flutter test -j 1
@@ -171,6 +171,7 @@ jobs:
       - name: Install dependencies
         run: |
           poetry install --with dev
+          poetry run pip install --no-deps -r requirements.txt
       - name: Lint with ruff
         run: |
           poetry run ruff check --format=github app

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 615 - 104
cli/src/api/open-api/api.ts


+ 1 - 1
cli/src/api/open-api/base.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.73.0
+ * The version of the OpenAPI document: 1.75.2
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/common.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.73.0
+ * The version of the OpenAPI document: 1.75.2
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/configuration.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.73.0
+ * The version of the OpenAPI document: 1.75.2
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
cli/src/api/open-api/index.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.73.0
+ * The version of the OpenAPI document: 1.75.2
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 2 - 2
cli/src/cli/base-command.ts

@@ -4,14 +4,14 @@ import { SessionService } from '../services/session.service';
 import { LoginError } from '../cores/errors/login-error';
 import { exit } from 'node:process';
 import os from 'os';
-import { ServerVersionReponseDto, UserResponseDto } from 'src/api/open-api';
+import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
 
 export abstract class BaseCommand {
   protected sessionService!: SessionService;
   protected immichApi!: ImmichApi;
   protected deviceId!: string;
   protected user!: UserResponseDto;
-  protected serverVersion!: ServerVersionReponseDto;
+  protected serverVersion!: ServerVersionResponseDto;
 
   protected configDir;
   protected authPath;

+ 2 - 2
docker/docker-compose.dev.yml

@@ -100,8 +100,8 @@ services:
     environment:
       - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
       - TYPESENSE_DATA_DIR=/data
-    logging:
-      driver: none
+      # remove this to get debug messages
+      - GLOG_minloglevel=1
     volumes:
       - tsdata:/data
 

+ 2 - 2
docker/docker-compose.prod.yml

@@ -68,8 +68,8 @@ services:
     environment:
       - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
       - TYPESENSE_DATA_DIR=/data
-    logging:
-      driver: none
+      # remove this to get debug messages
+      - GLOG_minloglevel=1
     volumes:
       - tsdata:/data
     restart: always

+ 2 - 0
docker/docker-compose.yml

@@ -54,6 +54,8 @@ services:
     environment:
       - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
       - TYPESENSE_DATA_DIR=/data
+      # remove this to get debug messages
+      - GLOG_minloglevel=1
     volumes:
       - tsdata:/data
     restart: always

+ 2 - 2
docs/docs/FAQ.md

@@ -39,7 +39,7 @@ This often happens when using a reverse proxy or cloudflare tunnel in front of I
 
 ### Why is Immich slow on low-memory systems like the Raspberry Pi?
 
-Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_URL=false` in your .env file.
+Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_ENABLED=false` in your .env file.
 
 ### How to disable machine-learning and TypeSense?
 
@@ -47,7 +47,7 @@ Immich uses optional machine-learning features to enhance search results. This f
 Disabling both will result in poor search experience and typesense utilizes CLIP embeddings which are generated by machine-learning.
 :::
 
-These features can be disabled by commenting out `immich-typesense` and `immich-machine-learning` sections of the docker-compose.yml and setting `IMMICH_MACHINE_LEARNING_URL=false` & `TYPESENSE_ENABLED=false` in your .env file.
+These features can be disabled by commenting out `immich-typesense` and `immich-machine-learning` sections of the docker-compose.yml and setting `IMMICH_MACHINE_LEARNING_ENABLED=false` & `TYPESENSE_ENABLED=false` in your .env file.
 
 ### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?
 

+ 91 - 0
docs/docs/install/config-file.md

@@ -0,0 +1,91 @@
+# Config File
+
+A config file can be provided as an alternative to the UI configuration.
+
+### Step 1 - Create a new config file
+
+In JSON format, create a new config file (e.g. `immich.config`) and put it in a location that can be accessed by Immich.
+The default configuration looks like this:
+
+```json
+{
+  "ffmpeg": {
+    "crf": 23,
+    "threads": 0,
+    "preset": "ultrafast",
+    "targetVideoCodec": "h264",
+    "targetAudioCodec": "aac",
+    "targetResolution": "720",
+    "maxBitrate": "0",
+    "twoPass": false,
+    "transcode": "required",
+    "tonemap": "hable",
+    "accel": "disabled"
+  },
+  "job": {
+    "backgroundTask": {
+      "concurrency": 5
+    },
+    "clipEncoding": {
+      "concurrency": 2
+    },
+    "metadataExtraction": {
+      "concurrency": 5
+    },
+    "objectTagging": {
+      "concurrency": 2
+    },
+    "recognizeFaces": {
+      "concurrency": 2
+    },
+    "search": {
+      "concurrency": 5
+    },
+    "sidecar": {
+      "concurrency": 5
+    },
+    "storageTemplateMigration": {
+      "concurrency": 5
+    },
+    "thumbnailGeneration": {
+      "concurrency": 5
+    },
+    "videoConversion": {
+      "concurrency": 1
+    }
+  },
+  "oauth": {
+    "enabled": false,
+    "issuerUrl": "",
+    "clientId": "",
+    "clientSecret": "",
+    "mobileOverrideEnabled": false,
+    "mobileRedirectUri": "",
+    "scope": "openid email profile",
+    "storageLabelClaim": "preferred_username",
+    "buttonText": "Login with OAuth",
+    "autoRegister": true,
+    "autoLaunch": false
+  },
+  "passwordLogin": {
+    "enabled": true
+  },
+  "storageTemplate": {
+    "template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"
+  },
+  "thumbnail": {
+    "webpSize": 250,
+    "jpegSize": 1440
+  }
+}
+```
+
+:::tip
+In Administration > Settings is a button to copy the current configuration to your clipboard.
+So you can just grab it from there, paste it into a file and you're pretty much good to go.
+:::
+
+### Step 2 - Specify the file location
+
+In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config.
+For more information, refer to the [Environment Variables](https://docs.immich.app/docs/install/environment-variables) section.

+ 0 - 1
docs/docs/install/docker-compose.md

@@ -132,7 +132,6 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
 
 IMMICH_WEB_URL=http://immich-web:3000
 IMMICH_SERVER_URL=http://immich-server:3001
-IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
 
 ####################################################################################
 # Alternative API's External Address - Optional

+ 13 - 7
docs/docs/install/environment-variables.md

@@ -1,3 +1,7 @@
+---
+sidebar_position: 90
+---
+
 # Environment Variables
 
 ## Docker Compose
@@ -22,6 +26,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
 | `LOG_LEVEL`                 | Log Level (verbose, debug, log, warn, error) |    `log`     | server, microservices                        |
 | `IMMICH_MEDIA_LOCATION`     | Media Location                               |  `./upload`  | server, microservices                        |
 | `PUBLIC_LOGIN_PAGE_MESSAGE` | Public Login Page Message                    |              | web                                          |
+| `IMMICH_CONFIG_FILE`        | Path to config file                          |              | server                                       |
 
 :::tip
 
@@ -50,13 +55,14 @@ These environment variables are used by the `docker-compose.yml` file and do **N
 
 ## URLs
 
-| Variable                      | Description                                              |                Default                | Services              |
-| :---------------------------- | :------------------------------------------------------- | :-----------------------------------: | :-------------------- |
-| `IMMICH_WEB_URL`              | Immich Web URL                                           |       `http://immich-web:3000`        | proxy                 |
-| `IMMICH_SERVER_URL`           | Immich Server URL                                        |      `http://immich-server:3001`      | web, proxy            |
-| `IMMICH_MACHINE_LEARNING_URL` | Immich Machine Learning URL, set `"false"` to disable ML | `http://immich-machine-learning:3003` | server, microservices |
-| `PUBLIC_IMMICH_SERVER_URL`    | Public Immich URL                                        |      `http://immich-server:3001`      | web                   |
-| `IMMICH_API_URL_EXTERNAL`     | Immich API URL External                                  |                `/api`                 | web                   |
+| Variable                          | Description                  |                Default                | Services              |
+| :-------------------------------- | :--------------------------- | :-----------------------------------: | :-------------------- |
+| `IMMICH_WEB_URL`                  | Immich Web URL               |       `http://immich-web:3000`        | proxy                 |
+| `IMMICH_SERVER_URL`               | Immich Server URL            |      `http://immich-server:3001`      | web, proxy            |
+| `IMMICH_MACHINE_LEARNING_ENABLED` | Enabled machine learning     |                `true`                 | server, microservices |
+| `IMMICH_MACHINE_LEARNING_URL`     | Immich Machine Learning URL, | `http://immich-machine-learning:3003` | server, microservices |
+| `PUBLIC_IMMICH_SERVER_URL`        | Public Immich URL            |      `http://immich-server:3001`      | web                   |
+| `IMMICH_API_URL_EXTERNAL`         | Immich API URL External      |                `/api`                 | web                   |
 
 :::info
 

+ 1 - 1
docs/docs/install/post-install.mdx

@@ -1,5 +1,5 @@
 ---
-sidebar_position: 100
+sidebar_position: 80
 ---
 
 import RegisterAdminUser from '../partials/_register-admin.md';

+ 2 - 1
machine-learning/Dockerfile

@@ -10,8 +10,9 @@ RUN poetry config installer.max-workers 10 && \
 RUN python -m venv /opt/venv
 ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
 
-COPY poetry.lock pyproject.toml ./
+COPY poetry.lock pyproject.toml requirements.txt ./
 RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
+RUN pip install --no-deps -r requirements.txt
 
 FROM python:3.11.4-slim-bullseye@sha256:91d194f58f50594cda71dcd2e8fdefd90e7ecc57d07823813b67c8521e565dcd
 

+ 11 - 4
machine-learning/app/config.py

@@ -1,3 +1,4 @@
+import os
 from pathlib import Path
 
 from pydantic import BaseSettings
@@ -8,25 +9,31 @@ from .schemas import ModelType
 class Settings(BaseSettings):
     cache_folder: str = "/cache"
     classification_model: str = "microsoft/resnet-50"
-    clip_image_model: str = "clip-ViT-B-32"
-    clip_text_model: str = "clip-ViT-B-32"
+    clip_image_model: str = "ViT-B-32::openai"
+    clip_text_model: str = "ViT-B-32::openai"
     facial_recognition_model: str = "buffalo_l"
     min_tag_score: float = 0.9
-    eager_startup: bool = True
+    eager_startup: bool = False
     model_ttl: int = 0
     host: str = "0.0.0.0"
     port: int = 3003
     workers: int = 1
     min_face_score: float = 0.7
     test_full: bool = False
+    request_threads: int = os.cpu_count() or 4
+    model_inter_op_threads: int = 1
+    model_intra_op_threads: int = 2
 
     class Config:
         env_prefix = "MACHINE_LEARNING_"
         case_sensitive = False
 
 
+_clean_name = str.maketrans(":\\/", "___", ".")
+
+
 def get_cache_dir(model_name: str, model_type: ModelType) -> Path:
-    return Path(settings.cache_folder, model_type.value, model_name)
+    return Path(settings.cache_folder) / model_type.value / model_name.translate(_clean_name)
 
 
 settings = Settings()

+ 29 - 14
machine-learning/app/main.py

@@ -1,4 +1,6 @@
+import asyncio
 import os
+from concurrent.futures import ThreadPoolExecutor
 from io import BytesIO
 from typing import Any
 
@@ -8,6 +10,8 @@ import uvicorn
 from fastapi import Body, Depends, FastAPI
 from PIL import Image
 
+from app.models.base import InferenceModel
+
 from .config import settings
 from .models.cache import ModelCache
 from .schemas import (
@@ -25,19 +29,21 @@ app = FastAPI()
 
 def init_state() -> None:
     app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0)
+    # asyncio is a huge bottleneck for performance, so we use a thread pool to run blocking code
+    app.state.thread_pool = ThreadPoolExecutor(settings.request_threads)
 
 
 async def load_models() -> None:
-    models = [
-        (settings.classification_model, ModelType.IMAGE_CLASSIFICATION),
-        (settings.clip_image_model, ModelType.CLIP),
-        (settings.clip_text_model, ModelType.CLIP),
-        (settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION),
+    models: list[tuple[str, ModelType, dict[str, Any]]] = [
+        (settings.classification_model, ModelType.IMAGE_CLASSIFICATION, {}),
+        (settings.clip_image_model, ModelType.CLIP, {"mode": "vision"}),
+        (settings.clip_text_model, ModelType.CLIP, {"mode": "text"}),
+        (settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION, {}),
     ]
 
     # Get all models
-    for model_name, model_type in models:
-        await app.state.model_cache.get(model_name, model_type, eager=settings.eager_startup)
+    for model_name, model_type, model_kwargs in models:
+        await app.state.model_cache.get(model_name, model_type, eager=settings.eager_startup, **model_kwargs)
 
 
 @app.on_event("startup")
@@ -46,11 +52,16 @@ async def startup_event() -> None:
     await load_models()
 
 
+@app.on_event("shutdown")
+async def shutdown_event() -> None:
+    app.state.thread_pool.shutdown()
+
+
 def dep_pil_image(byte_image: bytes = Body(...)) -> Image.Image:
     return Image.open(BytesIO(byte_image))
 
 
-def dep_cv_image(byte_image: bytes = Body(...)) -> cv2.Mat:
+def dep_cv_image(byte_image: bytes = Body(...)) -> np.ndarray[int, np.dtype[Any]]:
     byte_image_np = np.frombuffer(byte_image, np.uint8)
     return cv2.imdecode(byte_image_np, cv2.IMREAD_COLOR)
 
@@ -74,7 +85,7 @@ async def image_classification(
     image: Image.Image = Depends(dep_pil_image),
 ) -> list[str]:
     model = await app.state.model_cache.get(settings.classification_model, ModelType.IMAGE_CLASSIFICATION)
-    labels = model.predict(image)
+    labels = await predict(model, image)
     return labels
 
 
@@ -86,8 +97,8 @@ async def image_classification(
 async def clip_encode_image(
     image: Image.Image = Depends(dep_pil_image),
 ) -> list[float]:
-    model = await app.state.model_cache.get(settings.clip_image_model, ModelType.CLIP)
-    embedding = model.predict(image)
+    model = await app.state.model_cache.get(settings.clip_image_model, ModelType.CLIP, mode="vision")
+    embedding = await predict(model, image)
     return embedding
 
 
@@ -97,8 +108,8 @@ async def clip_encode_image(
     status_code=200,
 )
 async def clip_encode_text(payload: TextModelRequest) -> list[float]:
-    model = await app.state.model_cache.get(settings.clip_text_model, ModelType.CLIP)
-    embedding = model.predict(payload.text)
+    model = await app.state.model_cache.get(settings.clip_text_model, ModelType.CLIP, mode="text")
+    embedding = await predict(model, payload.text)
     return embedding
 
 
@@ -111,10 +122,14 @@ async def facial_recognition(
     image: cv2.Mat = Depends(dep_cv_image),
 ) -> list[dict[str, Any]]:
     model = await app.state.model_cache.get(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION)
-    faces = model.predict(image)
+    faces = await predict(model, image)
     return faces
 
 
+async def predict(model: InferenceModel, inputs: Any) -> Any:
+    return await asyncio.get_running_loop().run_in_executor(app.state.thread_pool, model.predict, inputs)
+
+
 if __name__ == "__main__":
     is_dev = os.getenv("NODE_ENV") == "development"
     uvicorn.run(

+ 1 - 1
machine-learning/app/models/__init__.py

@@ -1,3 +1,3 @@
-from .clip import CLIPSTEncoder
+from .clip import CLIPEncoder
 from .facial_recognition import FaceRecognizer
 from .image_classification import ImageClassifier

+ 37 - 2
machine-learning/app/models/base.py

@@ -1,14 +1,17 @@
 from __future__ import annotations
 
+import os
+import pickle
 from abc import ABC, abstractmethod
 from pathlib import Path
 from shutil import rmtree
 from typing import Any
 from zipfile import BadZipFile
 
+import onnxruntime as ort
 from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf  # type: ignore
 
-from ..config import get_cache_dir
+from ..config import get_cache_dir, settings
 from ..schemas import ModelType
 
 
@@ -16,12 +19,31 @@ class InferenceModel(ABC):
     _model_type: ModelType
 
     def __init__(
-        self, model_name: str, cache_dir: Path | str | None = None, eager: bool = True, **model_kwargs: Any
+        self,
+        model_name: str,
+        cache_dir: Path | str | None = None,
+        eager: bool = True,
+        inter_op_num_threads: int = settings.model_inter_op_threads,
+        intra_op_num_threads: int = settings.model_intra_op_threads,
+        **model_kwargs: Any,
     ) -> None:
         self.model_name = model_name
         self._loaded = False
         self._cache_dir = Path(cache_dir) if cache_dir is not None else get_cache_dir(model_name, self.model_type)
         loader = self.load if eager else self.download
+
+        self.providers = model_kwargs.pop("providers", ["CPUExecutionProvider"])
+        #  don't pre-allocate more memory than needed
+        self.provider_options = model_kwargs.pop(
+            "provider_options", [{"arena_extend_strategy": "kSameAsRequested"}] * len(self.providers)
+        )
+        self.sess_options = PicklableSessionOptions()
+        # avoid thread contention between models
+        if inter_op_num_threads > 1:
+            self.sess_options.execution_mode = ort.ExecutionMode.ORT_PARALLEL
+        self.sess_options.inter_op_num_threads = inter_op_num_threads
+        self.sess_options.intra_op_num_threads = intra_op_num_threads
+
         try:
             loader(**model_kwargs)
         except (OSError, InvalidProtobuf, BadZipFile):
@@ -30,6 +52,7 @@ class InferenceModel(ABC):
 
     def download(self, **model_kwargs: Any) -> None:
         if not self.cached:
+            print(f"Downloading {self.model_type.value.replace('_', ' ')} model. This may take a while...")
             self._download(**model_kwargs)
 
     def load(self, **model_kwargs: Any) -> None:
@@ -39,6 +62,7 @@ class InferenceModel(ABC):
 
     def predict(self, inputs: Any) -> Any:
         if not self._loaded:
+            print(f"Loading {self.model_type.value.replace('_', ' ')} model...")
             self.load()
         return self._predict(inputs)
 
@@ -89,3 +113,14 @@ class InferenceModel(ABC):
         else:
             self.cache_dir.unlink()
         self.cache_dir.mkdir(parents=True, exist_ok=True)
+
+
+# HF deep copies configs, so we need to make session options picklable
+class PicklableSessionOptions(ort.SessionOptions):
+    def __getstate__(self) -> bytes:
+        return pickle.dumps([(attr, getattr(self, attr)) for attr in dir(self) if not callable(getattr(self, attr))])
+
+    def __setstate__(self, state: Any) -> None:
+        self.__init__()  # type: ignore
+        for attr, val in pickle.loads(state):
+            setattr(self, attr, val)

+ 1 - 1
machine-learning/app/models/cache.py

@@ -46,7 +46,7 @@ class ModelCache:
             model: The requested model.
         """
 
-        key = self.cache.build_key(model_name, model_type.value)
+        key = f"{model_name}{model_type.value}{model_kwargs.get('mode', '')}"
         async with OptimisticLock(self.cache, key) as lock:
             model = await self.cache.get(key)
             if model is None:

+ 127 - 17
machine-learning/app/models/clip.py

@@ -1,31 +1,141 @@
-from typing import Any
+import os
+import zipfile
+from typing import Any, Literal
 
+import onnxruntime as ort
+import torch
+from clip_server.model.clip import BICUBIC, _convert_image_to_rgb
+from clip_server.model.clip_onnx import _MODELS, _S3_BUCKET_V2, CLIPOnnxModel, download_model
+from clip_server.model.pretrained_models import _VISUAL_MODEL_IMAGE_SIZE
+from clip_server.model.tokenization import Tokenizer
 from PIL.Image import Image
-from sentence_transformers import SentenceTransformer
-from sentence_transformers.util import snapshot_download
+from torchvision.transforms import CenterCrop, Compose, Normalize, Resize, ToTensor
 
 from ..schemas import ModelType
 from .base import InferenceModel
 
+_ST_TO_JINA_MODEL_NAME = {
+    "clip-ViT-B-16": "ViT-B-16::openai",
+    "clip-ViT-B-32": "ViT-B-32::openai",
+    "clip-ViT-B-32-multilingual-v1": "M-CLIP/XLM-Roberta-Large-Vit-B-32",
+    "clip-ViT-L-14": "ViT-L-14::openai",
+}
 
-class CLIPSTEncoder(InferenceModel):
+
+class CLIPEncoder(InferenceModel):
     _model_type = ModelType.CLIP
 
+    def __init__(
+        self,
+        model_name: str,
+        cache_dir: str | None = None,
+        mode: Literal["text", "vision"] | None = None,
+        **model_kwargs: Any,
+    ) -> None:
+        if mode is not None and mode not in ("text", "vision"):
+            raise ValueError(f"Mode must be 'text', 'vision', or omitted; got '{mode}'")
+        if "vit-b" not in model_name.lower():
+            raise ValueError(f"Only ViT-B models are currently supported; got '{model_name}'")
+        self.mode = mode
+        jina_model_name = self._get_jina_model_name(model_name)
+        super().__init__(jina_model_name, cache_dir, **model_kwargs)
+
     def _download(self, **model_kwargs: Any) -> None:
-        repo_id = self.model_name if "/" in self.model_name else f"sentence-transformers/{self.model_name}"
-        snapshot_download(
-            cache_dir=self.cache_dir,
-            repo_id=repo_id,
-            library_name="sentence-transformers",
-            ignore_files=["flax_model.msgpack", "rust_model.ot", "tf_model.h5"],
-        )
+        models: tuple[tuple[str, str], tuple[str, str]] = _MODELS[self.model_name]
+        text_onnx_path = self.cache_dir / "textual.onnx"
+        vision_onnx_path = self.cache_dir / "visual.onnx"
+
+        if not text_onnx_path.is_file():
+            self._download_model(*models[0])
+
+        if not vision_onnx_path.is_file():
+            self._download_model(*models[1])
 
     def _load(self, **model_kwargs: Any) -> None:
-        self.model = SentenceTransformer(
-            self.model_name,
-            cache_folder=self.cache_dir.as_posix(),
-            **model_kwargs,
-        )
+        if self.mode == "text" or self.mode is None:
+            self.text_model = ort.InferenceSession(
+                self.cache_dir / "textual.onnx",
+                sess_options=self.sess_options,
+                providers=self.providers,
+                provider_options=self.provider_options,
+            )
+            self.text_outputs = [output.name for output in self.text_model.get_outputs()]
+            self.tokenizer = Tokenizer(self.model_name)
+
+        if self.mode == "vision" or self.mode is None:
+            self.vision_model = ort.InferenceSession(
+                self.cache_dir / "visual.onnx",
+                sess_options=self.sess_options,
+                providers=self.providers,
+                provider_options=self.provider_options,
+            )
+            self.vision_outputs = [output.name for output in self.vision_model.get_outputs()]
+
+            image_size = _VISUAL_MODEL_IMAGE_SIZE[CLIPOnnxModel.get_model_name(self.model_name)]
+            self.transform = _transform_pil_image(image_size)
 
     def _predict(self, image_or_text: Image | str) -> list[float]:
-        return self.model.encode(image_or_text).tolist()
+        match image_or_text:
+            case Image():
+                if self.mode == "text":
+                    raise TypeError("Cannot encode image as text-only model")
+                pixel_values = self.transform(image_or_text)
+                assert isinstance(pixel_values, torch.Tensor)
+                pixel_values = torch.unsqueeze(pixel_values, 0).numpy()
+                outputs = self.vision_model.run(self.vision_outputs, {"pixel_values": pixel_values})
+            case str():
+                if self.mode == "vision":
+                    raise TypeError("Cannot encode text as vision-only model")
+                text_inputs: dict[str, torch.Tensor] = self.tokenizer(image_or_text)
+                inputs = {
+                    "input_ids": text_inputs["input_ids"].int().numpy(),
+                    "attention_mask": text_inputs["attention_mask"].int().numpy(),
+                }
+                outputs = self.text_model.run(self.text_outputs, inputs)
+            case _:
+                raise TypeError(f"Expected Image or str, but got: {type(image_or_text)}")
+
+        return outputs[0][0].tolist()
+
+    def _get_jina_model_name(self, model_name: str) -> str:
+        if model_name in _MODELS:
+            return model_name
+        elif model_name in _ST_TO_JINA_MODEL_NAME:
+            print(
+                (f"Warning: Sentence-Transformer model names such as '{model_name}' are no longer supported."),
+                (f"Using '{_ST_TO_JINA_MODEL_NAME[model_name]}' instead as it is the best match for '{model_name}'."),
+            )
+            return _ST_TO_JINA_MODEL_NAME[model_name]
+        else:
+            raise ValueError(f"Unknown model name {model_name}.")
+
+    def _download_model(self, model_name: str, model_md5: str) -> bool:
+        # downloading logic is adapted from clip-server's CLIPOnnxModel class
+        download_model(
+            url=_S3_BUCKET_V2 + model_name,
+            target_folder=self.cache_dir.as_posix(),
+            md5sum=model_md5,
+            with_resume=True,
+        )
+        file = self.cache_dir / model_name.split("/")[1]
+        if file.suffix == ".zip":
+            with zipfile.ZipFile(file, "r") as zip_ref:
+                zip_ref.extractall(self.cache_dir)
+            os.remove(file)
+        return True
+
+
+# same as `_transform_blob` without `_blob2image`
+def _transform_pil_image(n_px: int) -> Compose:
+    return Compose(
+        [
+            Resize(n_px, interpolation=BICUBIC),
+            CenterCrop(n_px),
+            _convert_image_to_rgb,
+            ToTensor(),
+            Normalize(
+                (0.48145466, 0.4578275, 0.40821073),
+                (0.26862954, 0.26130258, 0.27577711),
+            ),
+        ]
+    )

+ 21 - 4
machine-learning/app/models/facial_recognition.py

@@ -4,6 +4,7 @@ from typing import Any
 
 import cv2
 import numpy as np
+import onnxruntime as ort
 from insightface.model_zoo import ArcFaceONNX, RetinaFace
 from insightface.utils.face_align import norm_crop
 from insightface.utils.storage import BASE_REPO_URL, download_file
@@ -42,15 +43,31 @@ class FaceRecognizer(InferenceModel):
             rec_file = next(self.cache_dir.glob("w600k_*.onnx"))
         except StopIteration:
             raise FileNotFoundError("Facial recognition models not found in cache directory")
-        self.det_model = RetinaFace(det_file.as_posix())
-        self.rec_model = ArcFaceONNX(rec_file.as_posix())
+
+        self.det_model = RetinaFace(
+            session=ort.InferenceSession(
+                det_file.as_posix(),
+                sess_options=self.sess_options,
+                providers=self.providers,
+                provider_options=self.provider_options,
+            ),
+        )
+        self.rec_model = ArcFaceONNX(
+            rec_file.as_posix(),
+            session=ort.InferenceSession(
+                rec_file.as_posix(),
+                sess_options=self.sess_options,
+                providers=self.providers,
+                provider_options=self.provider_options,
+            ),
+        )
 
         self.det_model.prepare(
-            ctx_id=-1,
+            ctx_id=0,
             det_thresh=self.min_score,
             input_size=(640, 640),
         )
-        self.rec_model.prepare(ctx_id=-1)
+        self.rec_model.prepare(ctx_id=0)
 
     def _predict(self, image: cv2.Mat) -> list[dict[str, Any]]:
         bboxes, kpss = self.det_model.detect(image)

+ 28 - 7
machine-learning/app/models/image_classification.py

@@ -2,8 +2,10 @@ from pathlib import Path
 from typing import Any
 
 from huggingface_hub import snapshot_download
+from optimum.onnxruntime import ORTModelForImageClassification
+from optimum.pipelines import pipeline
 from PIL.Image import Image
-from transformers.pipelines import pipeline
+from transformers import AutoImageProcessor
 
 from ..config import settings
 from ..schemas import ModelType
@@ -25,15 +27,34 @@ class ImageClassifier(InferenceModel):
 
     def _download(self, **model_kwargs: Any) -> None:
         snapshot_download(
-            cache_dir=self.cache_dir, repo_id=self.model_name, allow_patterns=["*.bin", "*.json", "*.txt"]
+            cache_dir=self.cache_dir,
+            repo_id=self.model_name,
+            allow_patterns=["*.bin", "*.json", "*.txt"],
+            local_dir=self.cache_dir,
+            local_dir_use_symlinks=True,
         )
 
     def _load(self, **model_kwargs: Any) -> None:
-        self.model = pipeline(
-            self.model_type.value,
-            self.model_name,
-            model_kwargs={"cache_dir": self.cache_dir, **model_kwargs},
-        )
+        processor = AutoImageProcessor.from_pretrained(self.cache_dir)
+        model_kwargs |= {
+            "cache_dir": self.cache_dir,
+            "provider": self.providers[0],
+            "provider_options": self.provider_options[0],
+            "session_options": self.sess_options,
+        }
+        model_path = self.cache_dir / "model.onnx"
+
+        if model_path.exists():
+            model = ORTModelForImageClassification.from_pretrained(self.cache_dir, **model_kwargs)
+            self.model = pipeline(self.model_type.value, model, feature_extractor=processor)
+        else:
+            self.sess_options.optimized_model_filepath = model_path.as_posix()
+            self.model = pipeline(
+                self.model_type.value,
+                self.model_name,
+                model_kwargs=model_kwargs,
+                feature_extractor=processor,
+            )
 
     def _predict(self, image: Image) -> list[str]:
         predictions: list[dict[str, Any]] = self.model(image)  # type: ignore

+ 34 - 19
machine-learning/app/test_main.py

@@ -1,17 +1,20 @@
+import pickle
 from io import BytesIO
 from typing import TypeAlias
 from unittest import mock
 
 import cv2
 import numpy as np
+import onnxruntime as ort
 import pytest
 from fastapi.testclient import TestClient
 from PIL import Image
 from pytest_mock import MockerFixture
 
 from .config import settings
+from .models.base import PicklableSessionOptions
 from .models.cache import ModelCache
-from .models.clip import CLIPSTEncoder
+from .models.clip import CLIPEncoder
 from .models.facial_recognition import FaceRecognizer
 from .models.image_classification import ImageClassifier
 from .schemas import ModelType
@@ -72,45 +75,47 @@ class TestCLIP:
     embedding = np.random.rand(512).astype(np.float32)
 
     def test_eager_init(self, mocker: MockerFixture) -> None:
-        mocker.patch.object(CLIPSTEncoder, "download")
-        mock_load = mocker.patch.object(CLIPSTEncoder, "load")
-        clip_model = CLIPSTEncoder("test_model_name", cache_dir="test_cache", eager=True, test_arg="test_arg")
+        mocker.patch.object(CLIPEncoder, "download")
+        mock_load = mocker.patch.object(CLIPEncoder, "load")
+        clip_model = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", eager=True, test_arg="test_arg")
 
-        assert clip_model.model_name == "test_model_name"
+        assert clip_model.model_name == "ViT-B-32::openai"
         mock_load.assert_called_once_with(test_arg="test_arg")
 
     def test_lazy_init(self, mocker: MockerFixture) -> None:
-        mock_download = mocker.patch.object(CLIPSTEncoder, "download")
-        mock_load = mocker.patch.object(CLIPSTEncoder, "load")
-        clip_model = CLIPSTEncoder("test_model_name", cache_dir="test_cache", eager=False, test_arg="test_arg")
+        mock_download = mocker.patch.object(CLIPEncoder, "download")
+        mock_load = mocker.patch.object(CLIPEncoder, "load")
+        clip_model = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", eager=False, test_arg="test_arg")
 
-        assert clip_model.model_name == "test_model_name"
+        assert clip_model.model_name == "ViT-B-32::openai"
         mock_download.assert_called_once_with(test_arg="test_arg")
         mock_load.assert_not_called()
 
     def test_basic_image(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
-        mocker.patch.object(CLIPSTEncoder, "load")
-        clip_encoder = CLIPSTEncoder("test_model_name", cache_dir="test_cache")
-        clip_encoder.model = mock.Mock()
-        clip_encoder.model.encode.return_value = self.embedding
+        mocker.patch.object(CLIPEncoder, "download")
+        mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
+        mocked.return_value.run.return_value = [[self.embedding]]
+        clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="vision")
+        assert clip_encoder.mode == "vision"
         embedding = clip_encoder.predict(pil_image)
 
         assert isinstance(embedding, list)
         assert len(embedding) == 512
         assert all([isinstance(num, float) for num in embedding])
-        clip_encoder.model.encode.assert_called_once()
+        clip_encoder.vision_model.run.assert_called_once()
 
     def test_basic_text(self, mocker: MockerFixture) -> None:
-        mocker.patch.object(CLIPSTEncoder, "load")
-        clip_encoder = CLIPSTEncoder("test_model_name", cache_dir="test_cache")
-        clip_encoder.model = mock.Mock()
-        clip_encoder.model.encode.return_value = self.embedding
+        mocker.patch.object(CLIPEncoder, "download")
+        mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
+        mocked.return_value.run.return_value = [[self.embedding]]
+        clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="text")
+        assert clip_encoder.mode == "text"
         embedding = clip_encoder.predict("test search query")
 
         assert isinstance(embedding, list)
         assert len(embedding) == 512
         assert all([isinstance(num, float) for num in embedding])
-        clip_encoder.model.encode.assert_called_once()
+        clip_encoder.text_model.run.assert_called_once()
 
 
 class TestFaceRecognition:
@@ -254,3 +259,13 @@ class TestEndpoints:
             headers=headers,
         )
         assert response.status_code == 200
+
+
+def test_sess_options() -> None:
+    sess_options = PicklableSessionOptions()
+    sess_options.intra_op_num_threads = 1
+    sess_options.inter_op_num_threads = 1
+    pickled = pickle.dumps(sess_options)
+    unpickled = pickle.loads(pickled)
+    assert unpickled.intra_op_num_threads == 1
+    assert unpickled.inter_op_num_threads == 1

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 710 - 168
machine-learning/poetry.lock


+ 20 - 5
machine-learning/pyproject.toml

@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "machine-learning"
-version = "1.73.0"
+version = "1.75.2"
 description = ""
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 readme = "README.md"
@@ -13,7 +13,6 @@ torch = [
     {markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.0.1", source = "pytorch-cpu"}
 ]
 transformers = "^4.29.2"
-sentence-transformers = "^2.2.2"
 onnxruntime = "^1.15.0"
 insightface = "^0.7.3"
 opencv-python-headless = "^4.7.0.72"
@@ -22,6 +21,15 @@ fastapi = "^0.95.2"
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 pydantic = "^1.10.8"
 aiocache = "^0.12.1"
+optimum = "^1.9.1"
+torchvision = [
+    {markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=0.15.2", source = "pypi"},
+    {markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=0.15.2", source = "pytorch-cpu"}
+]
+rich = "^13.4.2"
+ftfy = "^6.1.1"
+setuptools = "^68.0.0"
+open-clip-torch = "^2.20.0"
 
 [tool.poetry.group.dev.dependencies]
 mypy = "^1.3.0"
@@ -62,13 +70,20 @@ warn_untyped_fields = true
 [[tool.mypy.overrides]]
 module = [
     "huggingface_hub",
-    "transformers.pipelines",
+    "transformers",
     "cv2",
     "insightface.model_zoo",
     "insightface.utils.face_align",
     "insightface.utils.storage",
-    "sentence_transformers",
-    "sentence_transformers.util",
+    "onnxruntime",
+    "optimum",
+    "optimum.pipelines",
+    "optimum.onnxruntime",
+    "clip_server.model.clip",
+    "clip_server.model.clip_onnx",
+    "clip_server.model.pretrained_models",
+    "clip_server.model.tokenization",
+    "torchvision.transforms",
     "aiocache.backends.memory",
     "aiocache.lock",
     "aiocache.plugins"

+ 2 - 0
machine-learning/requirements.txt

@@ -0,0 +1,2 @@
+# requirements to be installed with `--no-deps` flag
+clip-server==0.8.*

+ 1 - 1
mobile/.fvm/fvm_config.json

@@ -1,4 +1,4 @@
 {
-  "flutterSdkVersion": "3.10.5",
+  "flutterSdkVersion": "3.13.0",
   "flavors": {}
 }

+ 1 - 1
mobile/android/app/build.gradle

@@ -52,7 +52,7 @@ android {
     defaultConfig {
         // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
         applicationId "app.alextran.immich"
-        minSdkVersion 23
+        minSdkVersion 26
         targetSdkVersion 33
         versionCode flutterVersionCode.toInteger()
         versionName flutterVersionName

+ 2 - 1
mobile/android/app/src/main/AndroidManifest.xml

@@ -56,7 +56,7 @@
 
   <uses-permission android:name="android.permission.INTERNET" />
   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
-  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
   <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
   <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
   <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
@@ -64,6 +64,7 @@
   <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
   <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
   <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
 
   <queries>
     <intent>

+ 2 - 2
mobile/android/fastlane/Fastfile

@@ -35,8 +35,8 @@ platform :android do
       task: 'bundle', 
       build_type: 'Release',
       properties: {
-        "android.injected.version.code" => 96,
-        "android.injected.version.name" => "1.73.0",
+        "android.injected.version.code" => 98,
+        "android.injected.version.name" => "1.75.2",
       }
     )
     upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

+ 18 - 2
mobile/assets/i18n/en-US.json

@@ -300,5 +300,21 @@
   "version_announcement_overlay_text_1": "Hi friend, there is a new release of",
   "version_announcement_overlay_text_2": "please take your time to visit the ",
   "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
-  "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
-}
+  "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
+  "translated_text_options": "Options",
+  "map_no_assets_in_bounds": "No photos in this area",
+  "map_zoom_to_see_photos": "Zoom out to see photos",
+  "map_settings_dialog_title": "Map Settings",
+  "map_settings_dark_mode": "Dark mode",
+  "map_settings_only_show_favorites": "Show Favorite Only",
+  "map_settings_only_relative_range": "Date range",
+  "map_settings_dialog_cancel": "Cancel",
+  "map_settings_dialog_save": "Save",
+  "map_cannot_get_user_location": "Cannot get user's location",
+  "map_location_service_disabled_title": "Location Service disabled",
+  "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
+  "map_no_location_permission_title": "Location Permission denied",
+  "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
+  "map_location_dialog_cancel": "Cancel",
+  "map_location_dialog_yes": "Yes"
+}

BIN=BIN
mobile/assets/lighthouse.png


+ 19 - 13
mobile/ios/Podfile.lock

@@ -20,6 +20,8 @@ PODS:
   - FMDB (2.7.5):
     - FMDB/standard (= 2.7.5)
   - FMDB/standard (2.7.5)
+  - geolocator_apple (1.2.0):
+    - Flutter
   - image_picker_ios (0.0.1):
     - Flutter
   - integration_test (0.0.1):
@@ -33,7 +35,7 @@ PODS:
     - FlutterMacOS
   - path_provider_ios (0.0.1):
     - Flutter
-  - permission_handler_apple (9.0.4):
+  - permission_handler_apple (9.1.1):
     - Flutter
   - photo_manager (2.0.0):
     - Flutter
@@ -53,7 +55,7 @@ PODS:
     - Flutter
   - video_player_avfoundation (0.0.1):
     - Flutter
-  - wakelock (0.0.1):
+  - wakelock_plus (0.0.1):
     - Flutter
 
 DEPENDENCIES:
@@ -65,6 +67,7 @@ DEPENDENCIES:
   - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
   - flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
   - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
+  - geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
   - integration_test (from `.symlinks/plugins/integration_test/ios`)
   - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
@@ -78,7 +81,7 @@ DEPENDENCIES:
   - sqflite (from `.symlinks/plugins/sqflite/ios`)
   - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
   - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
-  - wakelock (from `.symlinks/plugins/wakelock/ios`)
+  - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
 
 SPEC REPOS:
   trunk:
@@ -104,6 +107,8 @@ EXTERNAL SOURCES:
     :path: ".symlinks/plugins/flutter_web_auth/ios"
   fluttertoast:
     :path: ".symlinks/plugins/fluttertoast/ios"
+  geolocator_apple:
+    :path: ".symlinks/plugins/geolocator_apple/ios"
   image_picker_ios:
     :path: ".symlinks/plugins/image_picker_ios/ios"
   integration_test:
@@ -130,8 +135,8 @@ EXTERNAL SOURCES:
     :path: ".symlinks/plugins/url_launcher_ios/ios"
   video_player_avfoundation:
     :path: ".symlinks/plugins/video_player_avfoundation/ios"
-  wakelock:
-    :path: ".symlinks/plugins/wakelock/ios"
+  wakelock_plus:
+    :path: ".symlinks/plugins/wakelock_plus/ios"
 
 SPEC CHECKSUMS:
   connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
@@ -141,26 +146,27 @@ SPEC CHECKSUMS:
   flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
   flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
   flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
-  fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
+  fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
   FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
+  geolocator_apple: cc556e6844d508c95df1e87e3ea6fa4e58c50401
   image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
   integration_test: 13825b8a9334a850581300559b8839134b124670
   isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
-  package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
-  path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8
+  package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
+  path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
   path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
-  permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce
+  permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
   photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
   ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
-  share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
-  shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
+  share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
+  shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
   sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
   Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
   url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
   video_player_avfoundation: 81e49bb3d9fb63dccf9fa0f6d877dc3ddbeac126
-  wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
+  wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
 
 PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
 
-COCOAPODS: 1.12.1
+COCOAPODS: 1.11.3

+ 1 - 1
mobile/ios/Runner.xcodeproj/project.pbxproj

@@ -171,7 +171,7 @@
 		97C146E61CF9000F007C117D /* Project object */ = {
 			isa = PBXProject;
 			attributes = {
-				LastUpgradeCheck = 1300;
+				LastUpgradeCheck = 1430;
 				ORGANIZATIONNAME = "";
 				TargetAttributes = {
 					97C146ED1CF9000F007C117D = {

+ 1 - 1
mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1300"
+   LastUpgradeVersion = "1430"
    version = "1.3">
    <BuildAction
       parallelizeBuildables = "YES"

+ 0 - 2
mobile/ios/Runner/Info.plist

@@ -83,8 +83,6 @@
     </dict>
     <key>NSCameraUsageDescription</key>
     <string>We need to access the camera to let you take beautiful video using this app</string>
-    <key>NSLocationAlwaysUsageDescription</key>
-    <string>Enable location setting to show position of assets on map</string>
     <key>NSLocationWhenInUseUsageDescription</key>
     <string>Enable location setting to show position of assets on map</string>
     <key>NSMicrophoneUsageDescription</key>

+ 1 - 1
mobile/ios/ci_scripts/ci_post_clone.sh

@@ -1,4 +1,4 @@
-#!/bin/sh
+#!/usr/bin/env sh
 
 # The default execution directory of this script is the ci_scripts directory.
 cd $CI_WORKSPACE/mobile

+ 1 - 1
mobile/ios/fastlane/Fastfile

@@ -19,7 +19,7 @@ platform :ios do
   desc "iOS Beta"
   lane :beta do
     increment_version_number(
-      version_number: "1.73.0"
+      version_number: "1.75.2"
     )
     increment_build_number(
       build_number: latest_testflight_build_number + 1,

+ 10 - 2
mobile/lib/main.dart

@@ -63,11 +63,15 @@ Future<void> initApp() async {
 
   FlutterError.onError = (details) {
     FlutterError.presentError(details);
-    log.severe(details.toString(), details, details.stack);
+    log.severe(
+      'Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}',
+      details,
+      details.stack,
+    );
   };
 
   PlatformDispatcher.instance.onError = (error, stack) {
-    log.severe(error.toString(), error, stack);
+    log.severe('Catch all error: ${error.toString()} - $error', error, stack);
     return true;
   };
 }
@@ -139,6 +143,10 @@ class ImmichAppState extends ConsumerState<ImmichApp>
         debugPrint("[APP STATE] detached");
         ref.read(appStateProvider.notifier).handleAppDetached();
         break;
+      case AppLifecycleState.hidden:
+        debugPrint("[APP STATE] hidden");
+        ref.read(appStateProvider.notifier).handleAppHidden();
+        break;
     }
   }
 

+ 10 - 0
mobile/lib/modules/album/providers/shared_album.provider.dart

@@ -56,6 +56,16 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
     return _albumService.removeAssetFromAlbum(album, assets);
   }
 
+  Future<bool> removeUserFromAlbum(Album album, User user) async {
+    final result = await _albumService.removeUserFromAlbum(album, user);
+
+    if (result && album.sharedUsers.isEmpty) {
+      state = state.where((element) => element.id != album.id).toList();
+    }
+
+    return result;
+  }
+
   @override
   void dispose() {
     _streamSub.cancel();

+ 20 - 0
mobile/lib/modules/album/services/album.service.dart

@@ -348,6 +348,26 @@ class AlbumService {
     }
   }
 
+  Future<bool> removeUserFromAlbum(
+    Album album,
+    User user,
+  ) async {
+    try {
+      await _apiService.albumApi.removeUserFromAlbum(
+        album.remoteId!,
+        user.id,
+      );
+
+      album.sharedUsers.remove(user);
+      await _db.writeTxn(() => album.sharedUsers.update(unlink: [user]));
+
+      return true;
+    } catch (e) {
+      debugPrint("Error removeUserFromAlbum  ${e.toString()}");
+      return false;
+    }
+  }
+
   Future<bool> changeTitleAlbum(
     Album album,
     String newAlbumTitle,

+ 3 - 3
mobile/lib/modules/album/ui/album_thumbnail_listtile.dart

@@ -49,7 +49,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
           type: ThumbnailFormat.JPEG,
         ),
         httpHeaders: {
-          "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
+          "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
         },
         cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
         errorWidget: (context, url, error) =>
@@ -105,9 +105,9 @@ class AlbumThumbnailListTile extends StatelessWidget {
                           style: TextStyle(
                             fontSize: 12,
                           ),
-                        ).tr()
+                        ).tr(),
                     ],
-                  )
+                  ),
                 ],
               ),
             ),

+ 5 - 0
mobile/lib/modules/album/ui/album_title_text_field.dart

@@ -69,6 +69,11 @@ class AlbumTitleTextField extends ConsumerWidget {
           borderRadius: BorderRadius.circular(10),
         ),
         hintText: 'share_add_title'.tr(),
+        hintStyle: TextStyle(
+          fontSize: 28,
+          color: isDarkTheme ? Colors.grey[300] : Colors.grey[700],
+          fontWeight: FontWeight.bold,
+        ),
         focusColor: Colors.grey[300],
         fillColor: isDarkTheme
             ? const Color.fromARGB(255, 32, 33, 35)

+ 98 - 32
mobile/lib/modules/album/ui/album_viewer_appbar.dart

@@ -39,7 +39,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
     final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
     final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
 
-    void onDeleteAlbumPressed() async {
+    deleteAlbum() async {
       ImmichLoadingOverlayController.appLoader.show();
 
       final bool success;
@@ -65,6 +65,52 @@ class AlbumViewerAppbar extends HookConsumerWidget
       ImmichLoadingOverlayController.appLoader.hide();
     }
 
+    Future<void> showConfirmationDialog() async {
+      return showDialog<void>(
+        context: context,
+        barrierDismissible: false, // user must tap button!
+        builder: (BuildContext context) {
+          return AlertDialog(
+            title: const Text('Delete album'),
+            content: const Text(
+              'Are you sure you want to delete this album from your account?',
+            ),
+            actions: <Widget>[
+              TextButton(
+                onPressed: () => Navigator.pop(context, 'Cancel'),
+                child: Text(
+                  'Cancel',
+                  style: TextStyle(
+                    color: Theme.of(context).primaryColor,
+                    fontWeight: FontWeight.bold,
+                  ),
+                ),
+              ),
+              TextButton(
+                onPressed: () {
+                  Navigator.pop(context, 'Confirm');
+                  deleteAlbum();
+                },
+                child: Text(
+                  'Confirm',
+                  style: TextStyle(
+                    fontWeight: FontWeight.bold,
+                    color: Theme.of(context).brightness == Brightness.light
+                        ? Colors.red
+                        : Colors.red[300],
+                  ),
+                ),
+              ),
+            ],
+          );
+        },
+      );
+    }
+
+    void onDeleteAlbumPressed() async {
+      showConfirmationDialog();
+    }
+
     void onLeaveAlbumPressed() async {
       ImmichLoadingOverlayController.appLoader.show();
 
@@ -152,43 +198,61 @@ class AlbumViewerAppbar extends HookConsumerWidget
     }
 
     void buildBottomSheet() {
+      final ownerActions = [
+        ListTile(
+          leading: const Icon(Icons.person_add_alt_rounded),
+          onTap: () {
+            Navigator.pop(context);
+            onAddUsers!(album);
+          },
+          title: const Text(
+            "album_viewer_page_share_add_users",
+            style: TextStyle(fontWeight: FontWeight.bold),
+          ).tr(),
+        ),
+        ListTile(
+          leading: const Icon(Icons.settings_rounded),
+          onTap: () =>
+              AutoRouter.of(context).navigate(AlbumOptionsRoute(album: album)),
+          title: const Text(
+            "translated_text_options",
+            style: TextStyle(fontWeight: FontWeight.bold),
+          ).tr(),
+        ),
+      ];
+
+      final commonActions = [
+        ListTile(
+          leading: const Icon(Icons.add_photo_alternate_outlined),
+          onTap: () {
+            Navigator.pop(context);
+            onAddPhotos!(album);
+          },
+          title: const Text(
+            "share_add_photos",
+            style: TextStyle(fontWeight: FontWeight.bold),
+          ).tr(),
+        ),
+      ];
       showModalBottomSheet(
         backgroundColor: Theme.of(context).scaffoldBackgroundColor,
         isScrollControlled: false,
         context: context,
         builder: (context) {
           return SafeArea(
-            child: Column(
-              mainAxisSize: MainAxisSize.min,
-              children: [
-                buildBottomSheetActionButton(),
-                if (selected.isEmpty && onAddPhotos != null)
-                  ListTile(
-                    leading: const Icon(Icons.add_photo_alternate_outlined),
-                    onTap: () {
-                      Navigator.pop(context);
-                      onAddPhotos!(album);
-                    },
-                    title: const Text(
-                      "share_add_photos",
-                      style: TextStyle(fontWeight: FontWeight.bold),
-                    ).tr(),
-                  ),
-                if (selected.isEmpty &&
-                    onAddPhotos != null &&
-                    userId == album.ownerId)
-                  ListTile(
-                    leading: const Icon(Icons.person_add_alt_rounded),
-                    onTap: () {
-                      Navigator.pop(context);
-                      onAddUsers!(album);
-                    },
-                    title: const Text(
-                      "album_viewer_page_share_add_users",
-                      style: TextStyle(fontWeight: FontWeight.bold),
-                    ).tr(),
-                  ),
-              ],
+            child: Padding(
+              padding: const EdgeInsets.only(top: 24.0),
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  buildBottomSheetActionButton(),
+                  if (selected.isEmpty && onAddPhotos != null) ...commonActions,
+                  if (selected.isEmpty &&
+                      onAddPhotos != null &&
+                      userId == album.ownerId)
+                    ...ownerActions,
+                ],
+              ),
             ),
           );
         },
@@ -217,6 +281,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
                 toastType: ToastType.error,
               );
             }
+
+            titleFocusNode.unfocus();
           },
           icon: const Icon(Icons.check_rounded),
           splashRadius: 25,

+ 5 - 0
mobile/lib/modules/album/ui/album_viewer_editable_title.dart

@@ -84,6 +84,11 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
             : Colors.grey[200],
         filled: titleFocusNode.hasFocus,
         hintText: 'share_add_title'.tr(),
+        hintStyle: TextStyle(
+          fontSize: 28,
+          color: isDarkTheme ? Colors.grey[300] : Colors.grey[700],
+          fontWeight: FontWeight.bold,
+        ),
       ),
     );
   }

+ 205 - 0
mobile/lib/modules/album/views/album_options_part.dart

@@ -0,0 +1,205 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:fluttertoast/fluttertoast.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
+import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/models/album.dart';
+import 'package:immich_mobile/shared/models/user.dart';
+import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
+import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
+
+class AlbumOptionsPage extends HookConsumerWidget {
+  final Album album;
+
+  const AlbumOptionsPage({super.key, required this.album});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final sharedUsers = useState(album.sharedUsers.toList());
+    final owner = album.owner.value;
+    final userId = ref.watch(authenticationProvider).userId;
+    final isOwner = owner?.id == userId;
+
+    void showErrorMessage() {
+      Navigator.pop(context);
+      ImmichToast.show(
+        context: context,
+        msg: "Error leaving/removing from album",
+        toastType: ToastType.error,
+        gravity: ToastGravity.BOTTOM,
+      );
+    }
+
+    void leaveAlbum() async {
+      ImmichLoadingOverlayController.appLoader.show();
+
+      try {
+        final isSuccess =
+            await ref.read(sharedAlbumProvider.notifier).leaveAlbum(album);
+
+        if (isSuccess) {
+          AutoRouter.of(context)
+              .navigate(const TabControllerRoute(children: [SharingRoute()]));
+        } else {
+          showErrorMessage();
+        }
+      } catch (_) {
+        showErrorMessage();
+      }
+
+      ImmichLoadingOverlayController.appLoader.hide();
+    }
+
+    void removeUserFromAlbum(User user) async {
+      ImmichLoadingOverlayController.appLoader.show();
+
+      try {
+        await ref
+            .read(sharedAlbumProvider.notifier)
+            .removeUserFromAlbum(album, user);
+        album.sharedUsers.remove(user);
+        sharedUsers.value = album.sharedUsers.toList();
+      } catch (error) {
+        showErrorMessage();
+      }
+
+      Navigator.pop(context);
+      ImmichLoadingOverlayController.appLoader.hide();
+    }
+
+    void handleUserClick(User user) {
+      var actions = [];
+
+      if (user.id == userId) {
+        actions = [
+          ListTile(
+            leading: const Icon(Icons.exit_to_app_rounded),
+            title: const Text("Leave album"),
+            onTap: leaveAlbum,
+          ),
+        ];
+      }
+
+      if (isOwner) {
+        actions = [
+          ListTile(
+            leading: const Icon(Icons.person_remove_rounded),
+            title: const Text("Remove user from album"),
+            onTap: () => removeUserFromAlbum(user),
+          ),
+        ];
+      }
+
+      showModalBottomSheet(
+        backgroundColor: Theme.of(context).scaffoldBackgroundColor,
+        isScrollControlled: false,
+        context: context,
+        builder: (context) {
+          return SafeArea(
+            child: Padding(
+              padding: const EdgeInsets.only(top: 24.0),
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [...actions],
+              ),
+            ),
+          );
+        },
+      );
+    }
+
+    buildOwnerInfo() {
+      return ListTile(
+        leading: owner != null
+            ? UserCircleAvatar(
+                user: owner,
+                useRandomBackgroundColor: true,
+              )
+            : const SizedBox(),
+        title: Text(
+          album.owner.value?.firstName ?? "",
+          style: const TextStyle(
+            fontWeight: FontWeight.bold,
+          ),
+        ),
+        subtitle: Text(
+          album.owner.value?.email ?? "",
+          style: TextStyle(color: Colors.grey[500]),
+        ),
+        trailing: const Text(
+          "Owner",
+          style: TextStyle(
+            fontWeight: FontWeight.bold,
+          ),
+        ),
+      );
+    }
+
+    buildSharedUsersList() {
+      return ListView.builder(
+        shrinkWrap: true,
+        itemCount: sharedUsers.value.length,
+        itemBuilder: (context, index) {
+          final user = sharedUsers.value[index];
+          return ListTile(
+            leading: UserCircleAvatar(
+              user: user,
+              useRandomBackgroundColor: true,
+              radius: 22,
+            ),
+            title: Text(
+              user.firstName,
+              style: const TextStyle(
+                fontWeight: FontWeight.bold,
+              ),
+            ),
+            subtitle: Text(
+              user.email,
+              style: TextStyle(color: Colors.grey[500]),
+            ),
+            trailing: userId == user.id || isOwner
+                ? const Icon(Icons.more_horiz_rounded)
+                : const SizedBox(),
+            onTap: userId == user.id || isOwner
+                ? () => handleUserClick(user)
+                : null,
+          );
+        },
+      );
+    }
+
+    buildSectionTitle(String text) {
+      return Padding(
+        padding: const EdgeInsets.all(16.0),
+        child: Text(text, style: Theme.of(context).textTheme.bodySmall),
+      );
+    }
+
+    return Scaffold(
+      appBar: AppBar(
+        leading: IconButton(
+          icon: const Icon(Icons.arrow_back_ios_new_rounded),
+          onPressed: () {
+            AutoRouter.of(context).pop(null);
+          },
+        ),
+        centerTitle: true,
+        title: Text("translated_text_options".tr()),
+      ),
+      body: Column(
+        mainAxisAlignment: MainAxisAlignment.start,
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          buildSectionTitle("PEOPLE"),
+          buildOwnerInfo(),
+          buildSharedUsersList(),
+        ],
+      ),
+    );
+  }
+}

+ 32 - 31
mobile/lib/modules/album/views/album_viewer_page.dart

@@ -17,6 +17,7 @@ import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 
 class AlbumViewerPage extends HookConsumerWidget {
@@ -116,7 +117,7 @@ class AlbumViewerPage extends HookConsumerWidget {
 
     Widget buildControlButton(Album album) {
       return Padding(
-        padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
+        padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16),
         child: SizedBox(
           height: 40,
           child: ListView(
@@ -141,7 +142,7 @@ class AlbumViewerPage extends HookConsumerWidget {
 
     Widget buildTitle(Album album) {
       return Padding(
-        padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
+        padding: const EdgeInsets.only(left: 8, right: 8, top: 24),
         child: userId == album.ownerId && album.isRemote
             ? AlbumViewerEditableTitle(
                 album: album,
@@ -172,7 +173,6 @@ class AlbumViewerPage extends HookConsumerWidget {
       return Padding(
         padding: EdgeInsets.only(
           left: 16.0,
-          top: 8.0,
           bottom: album.shared ? 0.0 : 8.0,
         ),
         child: Text(
@@ -180,7 +180,34 @@ class AlbumViewerPage extends HookConsumerWidget {
           style: const TextStyle(
             fontSize: 14,
             fontWeight: FontWeight.bold,
-            color: Colors.grey,
+          ),
+        ),
+      );
+    }
+
+    Widget buildSharedUserIconsRow(Album album) {
+      return GestureDetector(
+        onTap: () async {
+          await AutoRouter.of(context).push(AlbumOptionsRoute(album: album));
+          ref.invalidate(albumDetailProvider(album.id));
+        },
+        child: SizedBox(
+          height: 50,
+          child: ListView.builder(
+            padding: const EdgeInsets.only(left: 16),
+            scrollDirection: Axis.horizontal,
+            itemBuilder: ((context, index) {
+              return Padding(
+                padding: const EdgeInsets.only(right: 8.0),
+                child: UserCircleAvatar(
+                  user: album.sharedUsers.toList()[index],
+                  radius: 18,
+                  size: 36,
+                  useRandomBackgroundColor: true,
+                ),
+              );
+            }),
+            itemCount: album.sharedUsers.length,
           ),
         ),
       );
@@ -193,33 +220,7 @@ class AlbumViewerPage extends HookConsumerWidget {
         children: [
           buildTitle(album),
           if (album.assets.isNotEmpty == true) buildAlbumDateRange(album),
-          if (album.shared)
-            SizedBox(
-              height: 50,
-              child: ListView.builder(
-                padding: const EdgeInsets.only(left: 16),
-                scrollDirection: Axis.horizontal,
-                itemBuilder: ((context, index) {
-                  return Padding(
-                    padding: const EdgeInsets.only(right: 8.0),
-                    child: CircleAvatar(
-                      backgroundColor: Colors.grey[300],
-                      radius: 18,
-                      child: Padding(
-                        padding: const EdgeInsets.all(2.0),
-                        child: ClipRRect(
-                          borderRadius: BorderRadius.circular(50.0),
-                          child: Image.asset(
-                            'assets/immich-logo-no-outline.png',
-                          ),
-                        ),
-                      ),
-                    ),
-                  );
-                }),
-                itemCount: album.sharedUsers.length,
-              ),
-            ),
+          if (album.shared) buildSharedUserIconsRow(album),
         ],
       );
     }

+ 5 - 2
mobile/lib/modules/album/views/asset_selection_page.dart

@@ -73,9 +73,12 @@ class AssetSelectionPage extends HookConsumerWidget {
                 AutoRouter.of(context)
                     .popForced<AssetSelectionPageResult>(payload);
               },
-              child: const Text(
+              child: Text(
                 "share_add",
-                style: TextStyle(fontWeight: FontWeight.bold),
+                style: TextStyle(
+                  fontWeight: FontWeight.bold,
+                  color: Theme.of(context).primaryColor,
+                ),
               ).tr(),
             ),
         ],

+ 4 - 2
mobile/lib/modules/album/views/create_album_page.dart

@@ -30,7 +30,8 @@ class CreateAlbumPage extends HookConsumerWidget {
     final albumTitleTextFieldFocusNode = useFocusNode();
     final isAlbumTitleTextFieldFocus = useState(false);
     final isAlbumTitleEmpty = useState(true);
-    final selectedAssets = useState<Set<Asset>>(initialAssets != null ? Set.from(initialAssets!) : const {});
+    final selectedAssets = useState<Set<Asset>>(
+        initialAssets != null ? Set.from(initialAssets!) : const {},);
     final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
 
     showSelectUserPage() async {
@@ -248,8 +249,9 @@ class CreateAlbumPage extends HookConsumerWidget {
                   : null,
               child: Text(
                 'create_shared_album_page_create'.tr(),
-                style: const TextStyle(
+                style: TextStyle(
                   fontWeight: FontWeight.bold,
+                  color: Theme.of(context).primaryColor,
                 ),
               ),
             ),

+ 2 - 2
mobile/lib/modules/album/views/library_page.dart

@@ -60,7 +60,7 @@ class LibraryPage extends HookConsumerWidget {
     Widget buildSortButton() {
       final options = [
         "library_page_sort_created".tr(),
-        "library_page_sort_title".tr()
+        "library_page_sort_title".tr(),
       ];
 
       return PopupMenuButton(
@@ -87,7 +87,7 @@ class LibraryPage extends HookConsumerWidget {
                       color: selected ? Theme.of(context).primaryColor : null,
                       fontSize: 12.0,
                     ),
-                  )
+                  ),
                 ],
               ),
             );

+ 5 - 6
mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart

@@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/album/providers/suggested_shared_users.pro
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 
 class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
   final Album album;
@@ -35,10 +36,8 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
           ),
         );
       } else {
-        return CircleAvatar(
-          backgroundImage:
-              const AssetImage('assets/immich-logo-no-outline.png'),
-          backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
+        return UserCircleAvatar(
+          user: user,
         );
       }
     }
@@ -103,7 +102,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
                   } else {
                     sharedUsersList.value = {
                       ...sharedUsersList.value,
-                      users[index]
+                      users[index],
                     };
                   }
                 },
@@ -136,7 +135,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
               "share_add",
               style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
             ).tr(),
-          )
+          ),
         ],
       ),
       body: suggestedShareUsers.when(

+ 5 - 6
mobile/lib/modules/album/views/select_user_for_sharing_page.dart

@@ -10,6 +10,7 @@ import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 
 class SelectUserForSharingPage extends HookConsumerWidget {
   const SelectUserForSharingPage({Key? key, required this.assets})
@@ -56,10 +57,8 @@ class SelectUserForSharingPage extends HookConsumerWidget {
           ),
         );
       } else {
-        return CircleAvatar(
-          backgroundImage:
-              const AssetImage('assets/immich-logo-no-outline.png'),
-          backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
+        return UserCircleAvatar(
+          user: user,
         );
       }
     }
@@ -124,7 +123,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
                   } else {
                     sharedUsersList.value = {
                       ...sharedUsersList.value,
-                      users[index]
+                      users[index],
                     };
                   }
                 },
@@ -164,7 +163,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
                 // color: Theme.of(context).primaryColor,
               ),
             ).tr(),
-          )
+          ),
         ],
       ),
       body: suggestedShareUsers.when(

+ 1 - 1
mobile/lib/modules/album/views/sharing_page.dart

@@ -160,7 +160,7 @@ class SharingPage extends HookConsumerWidget {
                   maxLines: 1,
                 ).tr(),
               ),
-            )
+            ),
           ],
         ),
       );

+ 9 - 23
mobile/lib/modules/archive/views/archive_page.dart

@@ -2,14 +2,12 @@ import 'package:auto_route/auto_route.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
-import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:immich_mobile/utils/selection_handlers.dart';
 
 class ArchivePage extends HookConsumerWidget {
   const ArchivePage({super.key});
@@ -68,30 +66,18 @@ class ArchivePage extends HookConsumerWidget {
                         : () async {
                             processing.value = true;
                             try {
-                              if (selection.value.isNotEmpty) {
-                                await ref
-                                    .watch(assetProvider.notifier)
-                                    .toggleArchive(
-                                      selection.value.toList(),
-                                      false,
-                                    );
-
-                                final assetOrAssets = selection.value.length > 1
-                                    ? 'assets'
-                                    : 'asset';
-                                ImmichToast.show(
-                                  context: context,
-                                  msg:
-                                      'Moved ${selection.value.length} $assetOrAssets to library',
-                                  gravity: ToastGravity.CENTER,
-                                );
-                              }
+                              await handleArchiveAssets(
+                                ref,
+                                context,
+                                selection.value.toList(),
+                                shouldArchive: false,
+                              );
                             } finally {
                               processing.value = false;
                               selectionEnabledHook.value = false;
                             }
                           },
-                  )
+                  ),
                 ],
               ),
             ),
@@ -124,7 +110,7 @@ class ArchivePage extends HookConsumerWidget {
                   ),
                   if (selectionEnabledHook.value) buildBottomBar(),
                   if (processing.value)
-                    const Center(child: ImmichLoadingIndicator())
+                    const Center(child: ImmichLoadingIndicator()),
                 ],
               ),
       ),

+ 62 - 74
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter_map/flutter_map.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
+import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/ui/drag_sheet.dart';
 import 'package:latlong2/latlong.dart';
@@ -16,16 +17,35 @@ class ExifBottomSheet extends HookConsumerWidget {
 
   const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
 
-  bool get showMap =>
+  bool get hasCoordinates =>
       asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null;
 
-  Future<Uri> _createCoordinatesUri(double latitude, double longitude) async {
-    const zoomLevel = 5;
+  String get formattedDateTime {
+    final fileCreatedAt = asset.fileCreatedAt.toLocal();
+    final date = DateFormat.yMMMEd().format(fileCreatedAt);
+    final time = DateFormat.jm().format(fileCreatedAt);
+
+    return '$date • $time';
+  }
+
+  Future<Uri?> _createCoordinatesUri() async {
+    if (!hasCoordinates) {
+      return null;
+    }
+
+    double latitude = asset.exifInfo!.latitude!;
+    double longitude = asset.exifInfo!.longitude!;
+
+    const zoomLevel = 16;
+
     if (Platform.isAndroid) {
       Uri uri = Uri(
         scheme: 'geo',
         host: '$latitude,$longitude',
-        queryParameters: {'z': '$zoomLevel', 'q': '$latitude,$longitude'},
+        queryParameters: {
+          'z': '$zoomLevel',
+          'q': '$latitude,$longitude($formattedDateTime)',
+        },
       );
       if (await canLaunchUrl(uri)) {
         return uri;
@@ -33,16 +53,20 @@ class ExifBottomSheet extends HookConsumerWidget {
     } else if (Platform.isIOS) {
       var params = {
         'll': '$latitude,$longitude',
-        'q': '$latitude, $longitude',
+        'q': formattedDateTime,
+        'z': '$zoomLevel',
       };
       Uri uri = Uri.https('maps.apple.com', '/', params);
-      if (!await canLaunchUrl(uri)) {
+      if (await canLaunchUrl(uri)) {
         return uri;
       }
     }
-    return Uri.https(
-      'www.google.com',
-      '/maps/place/$latitude,$longitude/@$latitude,$longitude,${zoomLevel}z',
+
+    return Uri(
+      scheme: 'https',
+      host: 'openstreetmap.org',
+      queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
+      fragment: 'map=$zoomLevel/$latitude/$longitude',
     );
   }
 
@@ -57,67 +81,35 @@ class ExifBottomSheet extends HookConsumerWidget {
         padding: const EdgeInsets.symmetric(vertical: 16.0),
         child: LayoutBuilder(
           builder: (context, constraints) {
-            return Container(
-              height: 150,
-              width: constraints.maxWidth,
-              decoration: const BoxDecoration(
-                borderRadius: BorderRadius.all(Radius.circular(15)),
+            return MapThumbnail(
+              coords: LatLng(
+                exifInfo?.latitude ?? 0,
+                exifInfo?.longitude ?? 0,
               ),
-              child: FlutterMap(
-                options: MapOptions(
-                  interactiveFlags: InteractiveFlag.none,
-                  center: LatLng(
+              height: 150,
+              zoom: 16.0,
+              markers: [
+                Marker(
+                  anchorPos: AnchorPos.align(AnchorAlign.top),
+                  point: LatLng(
                     exifInfo?.latitude ?? 0,
                     exifInfo?.longitude ?? 0,
                   ),
-                  zoom: 16.0,
-                  onTap: (tapPosition, latLong) async {
-                    if (exifInfo != null &&
-                        exifInfo.latitude != null &&
-                        exifInfo.longitude != null) {
-                      launchUrl(
-                        await _createCoordinatesUri(
-                          exifInfo.latitude!,
-                          exifInfo.longitude!,
-                        ),
-                      );
-                    }
-                  },
-                ),
-                nonRotatedChildren: [
-                  RichAttributionWidget(
-                    attributions: [
-                      TextSourceAttribution(
-                        'OpenStreetMap contributors',
-                        onTap: () => launchUrl(
-                          Uri.parse('https://openstreetmap.org/copyright'),
-                        ),
-                      ),
-                    ],
-                  ),
-                ],
-                children: [
-                  TileLayer(
-                    urlTemplate:
-                        "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
-                    subdomains: const ['a', 'b', 'c'],
+                  builder: (ctx) => const Image(
+                    image: AssetImage('assets/location-pin.png'),
                   ),
-                  MarkerLayer(
-                    markers: [
-                      Marker(
-                        anchorPos: AnchorPos.align(AnchorAlign.top),
-                        point: LatLng(
-                          exifInfo?.latitude ?? 0,
-                          exifInfo?.longitude ?? 0,
-                        ),
-                        builder: (ctx) => const Image(
-                          image: AssetImage('assets/location-pin.png'),
-                        ),
-                      ),
-                    ],
-                  ),
-                ],
-              ),
+                ),
+              ],
+              onTap: (tapPosition, latLong) async {
+                Uri? uri = await _createCoordinatesUri();
+
+                if (uri == null) {
+                  return;
+                }
+
+                debugPrint('Opening Map Uri: $uri');
+                launchUrl(uri);
+              },
             );
           },
         ),
@@ -151,7 +143,7 @@ class ExifBottomSheet extends HookConsumerWidget {
 
     buildLocation() {
       // Guard no lat/lng
-      if (!showMap) {
+      if (!hasCoordinates) {
         return Container();
       }
 
@@ -199,7 +191,7 @@ class ExifBottomSheet extends HookConsumerWidget {
               Text(
                 "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
                 style: const TextStyle(fontSize: 12),
-              )
+              ),
             ],
           ),
         ],
@@ -207,12 +199,8 @@ class ExifBottomSheet extends HookConsumerWidget {
     }
 
     buildDate() {
-      final fileCreatedAt = asset.fileCreatedAt.toLocal();
-      final date = DateFormat.yMMMEd().format(fileCreatedAt);
-      final time = DateFormat.jm().format(fileCreatedAt);
-
       return Text(
-        '$date • $time',
+        formattedDateTime,
         style: const TextStyle(
           fontWeight: FontWeight.bold,
           fontSize: 14,
@@ -306,7 +294,7 @@ class ExifBottomSheet extends HookConsumerWidget {
                           crossAxisAlignment: CrossAxisAlignment.start,
                           children: [
                             Flexible(
-                              flex: showMap ? 5 : 0,
+                              flex: hasCoordinates ? 5 : 0,
                               child: Padding(
                                 padding: const EdgeInsets.only(right: 8.0),
                                 child: buildLocation(),
@@ -336,7 +324,7 @@ class ExifBottomSheet extends HookConsumerWidget {
                     if (asset.isRemote) DescriptionInput(asset: asset),
                     const SizedBox(height: 8.0),
                     buildLocation(),
-                    SizedBox(height: showMap ? 16.0 : 0.0),
+                    SizedBox(height: hasCoordinates ? 16.0 : 0.0),
                     buildDetail(),
                     const SizedBox(height: 50),
                   ],

+ 1 - 1
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart

@@ -128,7 +128,7 @@ class TopControlAppBar extends HookConsumerWidget {
         if (asset.isLocal && !asset.isRemote) buildUploadButton(),
         if (asset.isRemote && !asset.isLocal) buildDownloadButton(),
         if (asset.isRemote) buildAddToAlbumButtom(),
-        buildMoreInfoButton()
+        buildMoreInfoButton(),
       ],
     );
   }

+ 7 - 8
mobile/lib/modules/asset_viewer/views/video_viewer_page.dart

@@ -11,7 +11,7 @@ import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:video_player/video_player.dart';
-import 'package:wakelock/wakelock.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
 
 // ignore: must_be_immutable
 class VideoViewerPage extends HookConsumerWidget {
@@ -136,16 +136,16 @@ class _VideoPlayerState extends State<VideoPlayer> {
     videoPlayerController.addListener(() {
       if (videoPlayerController.value.isInitialized) {
         if (videoPlayerController.value.isPlaying) {
-          Wakelock.enable();
+          WakelockPlus.enable();
           widget.onPlaying?.call();
         } else if (!videoPlayerController.value.isPlaying) {
-          Wakelock.disable();
+          WakelockPlus.disable();
           widget.onPaused?.call();
         }
 
         if (videoPlayerController.value.position ==
             videoPlayerController.value.duration) {
-          Wakelock.disable();
+          WakelockPlus.disable();
           widget.onVideoEnded();
         }
       }
@@ -155,8 +155,8 @@ class _VideoPlayerState extends State<VideoPlayer> {
   Future<void> initializePlayer() async {
     try {
       videoPlayerController = widget.file == null
-          ? VideoPlayerController.network(
-              widget.url!,
+          ? VideoPlayerController.networkUrl(
+              Uri.parse(widget.url!),
               httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
             )
           : VideoPlayerController.file(widget.file!);
@@ -210,8 +210,7 @@ class _VideoPlayerState extends State<VideoPlayer> {
         child: Center(
           child: Stack(
             children: [
-              if (widget.placeholder != null)
-                widget.placeholder!,
+              if (widget.placeholder != null) widget.placeholder!,
               const Center(
                 child: ImmichLoadingIndicator(),
               ),

+ 1 - 1
mobile/lib/modules/backup/background_service/background.service.dart

@@ -90,7 +90,7 @@ class BackgroundService {
           requireUnmetered,
           requireCharging,
           triggerUpdateDelay,
-          triggerMaxDelay
+          triggerMaxDelay,
         ],
       );
       return ok;

+ 1 - 1
mobile/lib/modules/backup/providers/backup.provider.dart

@@ -511,7 +511,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
       state = state.copyWith(
         selectedAlbumsBackupAssetsIds: {
           ...state.selectedAlbumsBackupAssetsIds,
-          deviceAssetId
+          deviceAssetId,
         },
         allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
       );

+ 29 - 18
mobile/lib/modules/backup/providers/manual_upload.provider.dart

@@ -149,16 +149,30 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
   }
 
   Future<bool> _startUpload(Iterable<Asset> allManualUploads) async {
+    bool hasErrors = false;
     try {
       _backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
 
       if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
         await PhotoManager.clearFileCache();
 
-        Set<AssetEntity> allUploadAssets = allManualUploads
-            .where((e) => e.isLocal && e.local != null)
-            .map((e) => e.local!)
-            .toSet();
+        // We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases
+        // where platform specific fields such as `subtype` used to detect platform specific assets such as
+        // LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
+        List<AssetEntity?> allAssetsFromDevice = await Future.wait(
+          allManualUploads
+              // Filter local only assets
+              .where((e) => e.isLocal && !e.isRemote)
+              .map((e) => e.local!.obtainForNewProperties()),
+        );
+
+        if (allAssetsFromDevice.length != allManualUploads.length) {
+          _log.warning(
+            '[_startUpload] Refreshed upload list -> ${allManualUploads.length - allAssetsFromDevice.length} asset will not be uploaded',
+          );
+        }
+
+        Set<AssetEntity> allUploadAssets = allAssetsFromDevice.nonNulls.toSet();
 
         if (allUploadAssets.isEmpty) {
           debugPrint("[_startUpload] No Assets to upload - Abort Process");
@@ -213,7 +227,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
           '[_startUpload] Manual Upload Completed - success: ${state.successfulUploads},'
           ' failed: ${state.totalAssetsToUpload - state.successfulUploads}',
         );
-        bool hasErrors = false;
+
         // User cancelled upload
         if (!ok && state.cancelToken.isCancelled) {
           await _localNotificationService.showOrUpdateManualUploadStatus(
@@ -237,32 +251,29 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
             presentBanner: true,
           );
         }
-
-        _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
-        _handleAppInActivity();
-        await _backupProvider.notifyBackgroundServiceCanRun();
-        return !hasErrors;
       } else {
         openAppSettings();
         debugPrint("[_startUpload] Do not have permission to the gallery");
       }
     } catch (e) {
       debugPrint("ERROR _startUpload: ${e.toString()}");
+      hasErrors = true;
+    } finally {
+      _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
+      _handleAppInActivity();
+      await _localNotificationService.closeNotification(
+        LocalNotificationService.manualUploadDetailedNotificationID,
+      );
+      await _backupProvider.notifyBackgroundServiceCanRun();
     }
-    _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
-    _handleAppInActivity();
-    await _localNotificationService.closeNotification(
-      LocalNotificationService.manualUploadDetailedNotificationID,
-    );
-    await _backupProvider.notifyBackgroundServiceCanRun();
-    return false;
+    return !hasErrors;
   }
 
   void _handleAppInActivity() {
     final appState = ref.read(appStateProvider.notifier).getAppState();
     // The app is currently in background. Perform the necessary cleanups which
     // are on-hold for upload completion
-    if (appState != AppStateEnum.active || appState != AppStateEnum.resumed) {
+    if (appState != AppStateEnum.active && appState != AppStateEnum.resumed) {
       ref.read(appStateProvider.notifier).handleAppInactivity();
     }
   }

+ 15 - 3
mobile/lib/modules/backup/services/backup.service.dart

@@ -218,7 +218,18 @@ class BackupService {
     bool anyErrors = false;
     final List<String> duplicatedAssetIds = [];
 
-    for (var entity in assetList) {
+    // Upload images before video assets
+    // these are further sorted by using their creation date so the upload goes as follows
+    // older images -> latest images -> older videos -> latest videos
+    List<AssetEntity> sortedAssets = assetList.sorted(
+      (a, b) {
+        final cmp = a.typeInt - b.typeInt;
+        if (cmp != 0) return cmp;
+        return a.createDateTime.compareTo(b.createDateTime);
+      },
+    );
+
+    for (var entity in sortedAssets) {
       try {
         if (entity.type == AssetType.video) {
           file = await entity.originFile;
@@ -248,9 +259,10 @@ class BackupService {
 
           req.fields['deviceAssetId'] = entity.id;
           req.fields['deviceId'] = deviceId;
-          req.fields['fileCreatedAt'] = entity.createDateTime.toIso8601String();
+          req.fields['fileCreatedAt'] =
+              entity.createDateTime.toUtc().toIso8601String();
           req.fields['fileModifiedAt'] =
-              entity.modifiedDateTime.toIso8601String();
+              entity.modifiedDateTime.toUtc().toIso8601String();
           req.fields['isFavorite'] = entity.isFavorite.toString();
           req.fields['duration'] = entity.videoDuration.toString();
 

+ 2 - 2
mobile/lib/modules/backup/ui/album_info_card.dart

@@ -174,7 +174,7 @@ class AlbumInfoCard extends HookConsumerWidget {
                     bottom: 10,
                     right: 25,
                     child: buildSelectedTextBox(),
-                  )
+                  ),
                 ],
               ),
             ),
@@ -218,7 +218,7 @@ class AlbumInfoCard extends HookConsumerWidget {
                             }),
                             future: albumInfo.assetCount,
                           ),
-                        )
+                        ),
                       ],
                     ),
                   ),

+ 1 - 1
mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart

@@ -212,7 +212,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
                   Text(
                     " ${uploadProgress.toStringAsFixed(0)}%",
                     style: const TextStyle(fontSize: 12),
-                  )
+                  ),
                 ],
               ),
             ),

+ 2 - 2
mobile/lib/modules/backup/views/backup_album_selection_page.dart

@@ -247,7 +247,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
                   child: Wrap(
                     children: [
                       ...buildSelectedAlbumNameChip(),
-                      ...buildExcludedAlbumNameChip()
+                      ...buildExcludedAlbumNameChip(),
                     ],
                   ),
                 ),
@@ -301,7 +301,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
                             .watch(backupProvider)
                             .availableAlbums
                             .length
-                            .toString()
+                            .toString(),
                       ],
                     ),
                     style: const TextStyle(

+ 7 - 7
mobile/lib/modules/backup/views/backup_controller_page.dart

@@ -26,7 +26,7 @@ import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:permission_handler/permission_handler.dart';
 import 'package:url_launcher/url_launcher.dart';
-import 'package:wakelock/wakelock.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
 
 class BackupControllerPage extends HookConsumerWidget {
   const BackupControllerPage({Key? key}) : super(key: key);
@@ -114,7 +114,7 @@ class BackupControllerPage extends HookConsumerWidget {
           );
           return;
         }
-        Wakelock.enable();
+        WakelockPlus.enable();
         const limit = 100;
         final toDelete = await ref
             .read(backupVerificationServiceProvider)
@@ -140,7 +140,7 @@ class BackupControllerPage extends HookConsumerWidget {
           );
         }
       } finally {
-        Wakelock.disable();
+        WakelockPlus.disable();
         checkInProgress.value = false;
       }
     }
@@ -202,7 +202,7 @@ class BackupControllerPage extends HookConsumerWidget {
                 child: const Text('backup_controller_page_storage_format').tr(
                   args: [
                     backupState.serverInfo.diskUse,
-                    backupState.serverInfo.diskSize
+                    backupState.serverInfo.diskSize,
                   ],
                 ),
               ),
@@ -256,7 +256,7 @@ class BackupControllerPage extends HookConsumerWidget {
                     ),
                   ),
                 ),
-              )
+              ),
             ],
           ),
         ),
@@ -624,7 +624,7 @@ class BackupControllerPage extends HookConsumerWidget {
                   style: TextStyle(fontSize: 12),
                 ).tr(),
                 buildSelectedAlbumName(),
-                buildExcludedAlbumName()
+                buildExcludedAlbumName(),
               ],
             ),
           ),
@@ -776,7 +776,7 @@ class BackupControllerPage extends HookConsumerWidget {
             const Divider(),
             const CurrentUploadingAssetInfoBox(),
             if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
-            buildBackupButton()
+            buildBackupButton(),
           ],
         ),
       ),

+ 1 - 1
mobile/lib/modules/backup/views/failed_backup_status_page.dart

@@ -129,7 +129,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
                         ],
                       ),
                     ),
-                  )
+                  ),
                 ],
               ),
             ),

+ 8 - 15
mobile/lib/modules/favorite/views/favorites_page.dart

@@ -2,13 +2,11 @@ import 'package:auto_route/auto_route.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/providers/asset.provider.dart';
-import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:immich_mobile/utils/selection_handlers.dart';
 
 class FavoritesPage extends HookConsumerWidget {
   const FavoritesPage({Key? key}) : super(key: key);
@@ -44,16 +42,11 @@ class FavoritesPage extends HookConsumerWidget {
     void unfavorite() async {
       try {
         if (selection.value.isNotEmpty) {
-          await ref.watch(assetProvider.notifier).toggleFavorite(
-                selection.value.toList(),
-                false,
-              );
-          final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
-          ImmichToast.show(
-            context: context,
-            msg:
-                'Removed ${selection.value.length} $assetOrAssets from favorites',
-            gravity: ToastGravity.CENTER,
+          await handleFavoriteAssets(
+            ref,
+            context,
+            selection.value.toList(),
+            shouldFavorite: false,
           );
         }
       } finally {
@@ -83,7 +76,7 @@ class FavoritesPage extends HookConsumerWidget {
                       style: TextStyle(fontSize: 14),
                     ),
                     onTap: processing.value ? null : unfavorite,
-                  )
+                  ),
                 ],
               ),
             ),
@@ -108,7 +101,7 @@ class FavoritesPage extends HookConsumerWidget {
                         selectionActive: selectionEnabledHook.value,
                         listener: selectionListener,
                       ),
-                      if (selectionEnabledHook.value) buildBottomBar()
+                      if (selectionEnabledHook.value) buildBottomBar(),
                     ],
                   ),
           ),

+ 1 - 1
mobile/lib/modules/home/ui/asset_grid/group_divider_title.dart

@@ -57,7 +57,7 @@ class GroupDividerTitle extends ConsumerWidget {
                     Icons.check_circle_outline_rounded,
                     color: Colors.grey,
                   ),
-          )
+          ),
         ],
       ),
     );

+ 7 - 1
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart

@@ -30,6 +30,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
   final void Function(ItemPosition start, ItemPosition end)?
       visibleItemsListener;
   final Widget? topWidget;
+  final bool shrinkWrap;
+  final bool showDragScroll;
 
   const ImmichAssetGrid({
     super.key,
@@ -47,6 +49,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
     this.showMultiSelectIndicator = true,
     this.visibleItemsListener,
     this.topWidget,
+    this.shrinkWrap = false,
+    this.showDragScroll = true,
   });
 
   @override
@@ -89,7 +93,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
                 perRow.value = 7 - scaleFactor.value.toInt();
               }
             };
-          })
+          }),
         },
         child: ImmichAssetGridView(
           onRefresh: onRefresh,
@@ -108,6 +112,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
           visibleItemsListener: visibleItemsListener,
           topWidget: topWidget,
           heroOffset: heroOffset(),
+          shrinkWrap: shrinkWrap,
+          showDragScroll: showDragScroll,
         ),
       );
     }

+ 17 - 4
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart

@@ -35,6 +35,8 @@ class ImmichAssetGridView extends StatefulWidget {
       visibleItemsListener;
   final Widget? topWidget;
   final int heroOffset;
+  final bool shrinkWrap;
+  final bool showDragScroll;
 
   const ImmichAssetGridView({
     super.key,
@@ -52,6 +54,8 @@ class ImmichAssetGridView extends StatefulWidget {
     this.visibleItemsListener,
     this.topWidget,
     this.heroOffset = 0,
+    this.shrinkWrap = false,
+    this.showDragScroll = true,
   });
 
   @override
@@ -225,7 +229,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
               right: i + 1 == num ? 0.0 : widget.margin,
             ),
             color: Colors.grey,
-          )
+          ),
       ],
     );
   }
@@ -300,7 +304,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
   }
 
   Text _labelBuilder(int pos) {
-    final date = widget.renderList.elements[pos].date;
+    final maxLength = widget.renderList.elements.length;
+    if (pos < 0 || pos >= maxLength) {
+      return const Text("");
+    }
+
+    final date = widget.renderList.elements[pos % maxLength].date;
+
     return Text(
       DateFormat.yMMMM().format(date),
       style: const TextStyle(
@@ -318,7 +328,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
   }
 
   Widget _buildAssetGrid() {
-    final useDragScrolling = widget.renderList.totalAssets >= 20;
+    final useDragScrolling =
+        widget.showDragScroll && widget.renderList.totalAssets >= 20;
 
     void dragScrolling(bool active) {
       if (active != _scrolling) {
@@ -335,8 +346,10 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
       itemBuilder: _itemBuilder,
       itemPositionsListener: _itemPositionsListener,
       itemScrollController: _itemScrollController,
-      itemCount: widget.renderList.elements.length + (widget.topWidget != null ? 1 : 0),
+      itemCount: widget.renderList.elements.length +
+          (widget.topWidget != null ? 1 : 0),
       addRepaintBoundaries: true,
+      shrinkWrap: widget.shrinkWrap,
     );
 
     final child = useDragScrolling

+ 1 - 1
mobile/lib/modules/home/ui/control_bottom_app_bar.dart

@@ -155,7 +155,7 @@ class ControlBottomAppBar extends ConsumerWidget {
               if (hasRemote)
                 const SliverToBoxAdapter(
                   child: SizedBox(height: 200),
-                )
+                ),
             ],
           ),
         );

+ 6 - 4
mobile/lib/modules/home/ui/home_page_app_bar.dart

@@ -1,7 +1,8 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 
@@ -29,9 +30,9 @@ class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
         backupState.backgroundBackup || backupState.autoBackup;
     final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
     AuthenticationState authState = ref.watch(authenticationProvider);
-
+    final user = Store.tryGet(StoreKey.currentUser);
     buildProfilePhoto() {
-      if (authState.profileImagePath.isEmpty) {
+      if (authState.profileImagePath.isEmpty || user == null) {
         return IconButton(
           splashRadius: 25,
           icon: const Icon(
@@ -47,9 +48,10 @@ class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
           onTap: () {
             Scaffold.of(context).openDrawer();
           },
-          child: const UserCircleAvatar(
+          child: UserCircleAvatar(
             radius: 18,
             size: 33,
+            user: user,
           ),
         );
       }

+ 1 - 1
mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart

@@ -108,7 +108,7 @@ class ProfileDrawer extends HookConsumerWidget {
               buildSignOutButton(),
             ],
           ),
-          const ServerInfoBox()
+          const ServerInfoBox(),
         ],
       ),
     );

+ 11 - 8
mobile/lib/modules/home/ui/profile_drawer/profile_drawer_header.dart

@@ -3,7 +3,8 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:image_picker/image_picker.dart';
 import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
-import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@@ -19,14 +20,10 @@ class ProfileDrawerHeader extends HookConsumerWidget {
     final uploadProfileImageStatus =
         ref.watch(uploadProfileImageProvider).status;
     final isDarkMode = Theme.of(context).brightness == Brightness.dark;
+    final user = Store.tryGet(StoreKey.currentUser);
 
     buildUserProfileImage() {
-      var userImage = const UserCircleAvatar(
-        radius: 35,
-        size: 66,
-      );
-
-      if (authState.profileImagePath.isEmpty) {
+      if (authState.profileImagePath.isEmpty || user == null) {
         return const CircleAvatar(
           radius: 35,
           backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
@@ -34,6 +31,12 @@ class ProfileDrawerHeader extends HookConsumerWidget {
         );
       }
 
+      var userImage = UserCircleAvatar(
+        radius: 35,
+        size: 66,
+        user: user,
+      );
+
       if (uploadProfileImageStatus == UploadProfileStatus.idle) {
         if (authState.profileImagePath.isNotEmpty) {
           return userImage;
@@ -153,7 +156,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
           Text(
             authState.userEmail,
             style: Theme.of(context).textTheme.labelMedium,
-          )
+          ),
         ],
       ),
     );

+ 0 - 44
mobile/lib/modules/home/ui/user_circle_avatar.dart

@@ -1,44 +0,0 @@
-import 'dart:math';
-
-import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
-import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
-import 'package:immich_mobile/shared/models/store.dart';
-import 'package:immich_mobile/shared/ui/transparent_image.dart';
-
-class UserCircleAvatar extends ConsumerWidget {
-  final double radius;
-  final double size;
-  const UserCircleAvatar({super.key, required this.radius, required this.size});
-
-  @override
-  Widget build(BuildContext context, WidgetRef ref) {
-    AuthenticationState authState = ref.watch(authenticationProvider);
-
-    var profileImageUrl =
-        '${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${authState.userId}?d=${Random().nextInt(1024)}';
-    return CircleAvatar(
-      backgroundColor: Theme.of(context).primaryColor,
-      radius: radius,
-      child: ClipRRect(
-        borderRadius: BorderRadius.circular(50),
-        child: FadeInImage(
-          fit: BoxFit.cover,
-          placeholder: MemoryImage(kTransparentImage),
-          width: size,
-          height: size,
-          image: NetworkImage(
-            profileImageUrl,
-            headers: {
-              "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
-            },
-          ),
-          fadeInDuration: const Duration(milliseconds: 200),
-          imageErrorBuilder: (context, error, stackTrace) =>
-              Image.memory(kTransparentImage),
-        ),
-      ),
-    );
-  }
-}

+ 7 - 38
mobile/lib/modules/home/views/home_page.dart

@@ -25,10 +25,9 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/user.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
-import 'package:immich_mobile/shared/services/share.service.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
-import 'package:immich_mobile/shared/ui/share_dialog.dart';
+import 'package:immich_mobile/utils/selection_handlers.dart';
 
 class HomePage extends HookConsumerWidget {
   const HomePage({Key? key}) : super(key: key);
@@ -88,17 +87,7 @@ class HomePage extends HookConsumerWidget {
       }
 
       void onShareAssets() {
-        showDialog(
-          context: context,
-          builder: (BuildContext buildContext) {
-            ref
-                .watch(shareServiceProvider)
-                .shareAssets(selection.value.toList())
-                .then((_) => Navigator.of(buildContext).pop());
-            return const ShareDialog();
-          },
-          barrierDismissible: false,
-        );
+        handleShareAssets(ref, context, selection.value.toList());
 
         selectionEnabledHook.value = false;
       }
@@ -126,16 +115,7 @@ class HomePage extends HookConsumerWidget {
             localErrorMessage: 'home_page_favorite_err_local'.tr(),
           );
           if (remoteAssets.isNotEmpty) {
-            await ref
-                .watch(assetProvider.notifier)
-                .toggleFavorite(remoteAssets, true);
-
-            final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
-            ImmichToast.show(
-              context: context,
-              msg: 'Added ${remoteAssets.length} $assetOrAssets to favorites',
-              gravity: ToastGravity.BOTTOM,
-            );
+            await handleFavoriteAssets(ref, context, remoteAssets);
           }
         } finally {
           processing.value = false;
@@ -149,18 +129,7 @@ class HomePage extends HookConsumerWidget {
           final remoteAssets = remoteOnlySelection(
             localErrorMessage: 'home_page_archive_err_local'.tr(),
           );
-          if (remoteAssets.isNotEmpty) {
-            await ref
-                .read(assetProvider.notifier)
-                .toggleArchive(remoteAssets, true);
-
-            final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
-            ImmichToast.show(
-              context: context,
-              msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive',
-              gravity: ToastGravity.CENTER,
-            );
-          }
+          await handleArchiveAssets(ref, context, remoteAssets);
         } finally {
           processing.value = false;
           selectionEnabledHook.value = false;
@@ -221,7 +190,7 @@ class HomePage extends HookConsumerWidget {
                   namedArgs: {
                     "album": album.name,
                     "added": result.successfullyAdded.toString(),
-                    "failed": result.alreadyInAlbum.length.toString()
+                    "failed": result.alreadyInAlbum.length.toString(),
                   },
                 ),
               );
@@ -323,7 +292,7 @@ class HomePage extends HookConsumerWidget {
                     ).tr(),
                   ),
                 ),
-              )
+              ),
             ],
           ),
         );
@@ -365,7 +334,7 @@ class HomePage extends HookConsumerWidget {
                 enabled: !processing.value,
                 selectionAssetState: selectionAssetState.value,
               ),
-            if (processing.value) const Center(child: ImmichLoadingIndicator())
+            if (processing.value) const Center(child: ImmichLoadingIndicator()),
           ],
         ),
       );

+ 1 - 1
mobile/lib/modules/login/ui/change_password_form.dart

@@ -94,7 +94,7 @@ class ChangePasswordForm extends HookConsumerWidget {
                     ),
                   ],
                 ),
-              )
+              ),
             ],
           ),
         ),

+ 40 - 0
mobile/lib/modules/map/models/map_page_event.model.dart

@@ -0,0 +1,40 @@
+import 'package:immich_mobile/shared/models/asset.dart';
+
+enum MapPageEventType {
+  mapTap,
+  bottomSheetScrolled,
+  assetsInBoundUpdated,
+  zoomToAsset,
+  zoomToCurrentLocation,
+}
+
+class MapPageEventBase {
+  final MapPageEventType type;
+
+  const MapPageEventBase(this.type);
+}
+
+class MapPageOnTapEvent extends MapPageEventBase {
+  const MapPageOnTapEvent() : super(MapPageEventType.mapTap);
+}
+
+class MapPageAssetsInBoundUpdated extends MapPageEventBase {
+  List<Asset> assets;
+  MapPageAssetsInBoundUpdated(this.assets)
+      : super(MapPageEventType.assetsInBoundUpdated);
+}
+
+class MapPageBottomSheetScrolled extends MapPageEventBase {
+  Asset? asset;
+  MapPageBottomSheetScrolled(this.asset)
+      : super(MapPageEventType.bottomSheetScrolled);
+}
+
+class MapPageZoomToAsset extends MapPageEventBase {
+  Asset? asset;
+  MapPageZoomToAsset(this.asset) : super(MapPageEventType.zoomToAsset);
+}
+
+class MapPageZoomToLocation extends MapPageEventBase {
+  const MapPageZoomToLocation() : super(MapPageEventType.zoomToCurrentLocation);
+}

+ 45 - 0
mobile/lib/modules/map/models/map_state.model.dart

@@ -0,0 +1,45 @@
+class MapState {
+  final bool isDarkTheme;
+  final bool showFavoriteOnly;
+  final int relativeTime;
+
+  MapState({
+    this.isDarkTheme = false,
+    this.showFavoriteOnly = false,
+    this.relativeTime = 0,
+  });
+
+  MapState copyWith({
+    bool? isDarkTheme,
+    bool? showFavoriteOnly,
+    int? relativeTime,
+  }) {
+    return MapState(
+      isDarkTheme: isDarkTheme ?? this.isDarkTheme,
+      showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
+      relativeTime: relativeTime ?? this.relativeTime,
+    );
+  }
+
+  @override
+  String toString() {
+    return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime)';
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is MapState &&
+        other.isDarkTheme == isDarkTheme &&
+        other.showFavoriteOnly == showFavoriteOnly &&
+        other.relativeTime == relativeTime;
+  }
+
+  @override
+  int get hashCode {
+    return isDarkTheme.hashCode ^
+        showFavoriteOnly.hashCode ^
+        relativeTime.hashCode;
+  }
+}

+ 58 - 0
mobile/lib/modules/map/providers/map_marker.provider.dart

@@ -0,0 +1,58 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
+import 'package:immich_mobile/modules/map/services/map.service.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:latlong2/latlong.dart';
+
+final mapMarkersProvider =
+    FutureProvider.autoDispose<Set<AssetMarkerData>>((ref) async {
+  final service = ref.read(mapServiceProvider);
+  final mapState = ref.read(mapStateNotifier);
+  DateTime? fileCreatedAfter;
+  bool? isFavorite;
+
+  if (mapState.relativeTime != 0) {
+    fileCreatedAfter =
+        DateTime.now().subtract(Duration(days: mapState.relativeTime));
+  }
+
+  if (mapState.showFavoriteOnly) {
+    isFavorite = true;
+  }
+
+  final markers = await service.getMapMarkers(
+    isFavorite: isFavorite,
+    fileCreatedAfter: fileCreatedAfter,
+  );
+
+  final assetMarkerData = await Future.wait(
+    markers.map((e) async {
+      final asset = await service.getAssetForMarkerId(e.id);
+      bool hasInvalidCoords = e.lat < -90 || e.lat > 90;
+      hasInvalidCoords = hasInvalidCoords || (e.lon < -180 || e.lon > 180);
+      if (asset == null || hasInvalidCoords) return null;
+      return AssetMarkerData(asset, LatLng(e.lat, e.lon));
+    }),
+  );
+
+  return assetMarkerData.nonNulls.toSet();
+});
+
+class AssetMarkerData {
+  final LatLng point;
+  final Asset asset;
+
+  const AssetMarkerData(this.asset, this.point);
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+
+    return other is AssetMarkerData && other.asset.remoteId == asset.remoteId;
+  }
+
+  @override
+  int get hashCode {
+    return asset.remoteId.hashCode;
+  }
+}

+ 51 - 0
mobile/lib/modules/map/providers/map_state.provider.dart

@@ -0,0 +1,51 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/map/models/map_state.model.dart';
+import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
+import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+
+class MapStateNotifier extends StateNotifier<MapState> {
+  MapStateNotifier(this.appSettingsProvider)
+      : super(
+          MapState(
+            isDarkTheme: appSettingsProvider
+                .getSetting<bool>(AppSettingsEnum.mapThemeMode),
+            showFavoriteOnly: appSettingsProvider
+                .getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
+            relativeTime: appSettingsProvider
+                .getSetting<int>(AppSettingsEnum.mapRelativeDate),
+          ),
+        );
+
+  final AppSettingsService appSettingsProvider;
+
+  bool get isDarkTheme => state.isDarkTheme;
+
+  void switchTheme(bool isDarkTheme) {
+    appSettingsProvider.setSetting(
+      AppSettingsEnum.mapThemeMode,
+      isDarkTheme,
+    );
+    state = state.copyWith(isDarkTheme: isDarkTheme);
+  }
+
+  void switchFavoriteOnly(bool isFavoriteOnly) {
+    appSettingsProvider.setSetting(
+      AppSettingsEnum.mapShowFavoriteOnly,
+      appSettingsProvider,
+    );
+    state = state.copyWith(showFavoriteOnly: isFavoriteOnly);
+  }
+
+  void setRelativeTime(int relativeTime) {
+    appSettingsProvider.setSetting(
+      AppSettingsEnum.mapRelativeDate,
+      relativeTime,
+    );
+    state = state.copyWith(relativeTime: relativeTime);
+  }
+}
+
+final mapStateNotifier =
+    StateNotifierProvider<MapStateNotifier, MapState>((ref) {
+  return MapStateNotifier(ref.watch(appSettingsServiceProvider));
+});

+ 62 - 0
mobile/lib/modules/map/services/map.service.dart

@@ -0,0 +1,62 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:isar/isar.dart';
+import 'package:logging/logging.dart';
+import 'package:openapi/api.dart';
+
+final mapServiceProvider = Provider(
+  (ref) => MapSerivce(
+    ref.read(apiServiceProvider),
+    ref.read(dbProvider),
+  ),
+);
+
+class MapSerivce {
+  final ApiService _apiService;
+  final Isar _db;
+  final log = Logger("MapService");
+
+  MapSerivce(this._apiService, this._db);
+
+  Future<List<MapMarkerResponseDto>> getMapMarkers({
+    bool? isFavorite,
+    DateTime? fileCreatedAfter,
+    DateTime? fileCreatedBefore,
+  }) async {
+    try {
+      final markers = await _apiService.assetApi.getMapMarkers(
+        isFavorite: isFavorite,
+        fileCreatedAfter: fileCreatedAfter,
+        fileCreatedBefore: fileCreatedBefore,
+      );
+
+      return markers ?? [];
+    } catch (error, stack) {
+      log.severe("Cannot get map markers ${error.toString()}", error, stack);
+      return [];
+    }
+  }
+
+  Future<Asset?> getAssetForMarkerId(String remoteId) async {
+    try {
+      final assets = await _db.assets.getAllByRemoteId([remoteId]);
+      if (assets.isNotEmpty) return assets[0];
+
+      final dto = await _apiService.assetApi.getAssetById(remoteId);
+      if (dto == null) {
+        return null;
+      }
+      return Asset.remote(dto);
+    } catch (error, stack) {
+      log.severe(
+        "Cannot get asset for marker ${error.toString()}",
+        error,
+        stack,
+      );
+      return null;
+    }
+  }
+}

+ 144 - 0
mobile/lib/modules/map/ui/asset_marker_icon.dart

@@ -0,0 +1,144 @@
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
+
+class AssetMarkerIcon extends StatelessWidget {
+  const AssetMarkerIcon({
+    Key? key,
+    required this.id,
+    this.isDarkTheme = false,
+  }) : super(key: key);
+
+  final String id;
+  final bool isDarkTheme;
+
+  @override
+  Widget build(BuildContext context) {
+    final imageUrl = getThumbnailUrlForRemoteId(id);
+    final cacheKey = getThumbnailCacheKeyForRemoteId(id);
+    return LayoutBuilder(
+      builder: (context, constraints) {
+        return Stack(
+          children: [
+            Positioned(
+              bottom: 0,
+              left: constraints.maxWidth * 0.5,
+              child: CustomPaint(
+                painter: _PinPainter(
+                  primaryColor: isDarkTheme ? Colors.white : Colors.black,
+                  secondaryColor: isDarkTheme ? Colors.black : Colors.white,
+                  primaryRadius: constraints.maxHeight * 0.06,
+                  secondaryRadius: constraints.maxHeight * 0.038,
+                ),
+                child: SizedBox(
+                  height: constraints.maxHeight * 0.14,
+                  width: constraints.maxWidth * 0.14,
+                ),
+              ),
+            ),
+            Positioned(
+              top: constraints.maxHeight * 0.07,
+              left: constraints.maxWidth * 0.17,
+              child: CircleAvatar(
+                radius: constraints.maxHeight * 0.40,
+                backgroundColor: isDarkTheme ? Colors.white : Colors.black,
+                child: CircleAvatar(
+                  radius: constraints.maxHeight * 0.37,
+                  backgroundImage: CachedNetworkImageProvider(
+                    imageUrl,
+                    cacheKey: cacheKey,
+                    headers: {
+                      "Authorization":
+                          "Bearer ${Store.get(StoreKey.accessToken)}",
+                    },
+                    errorListener: () =>
+                        const Icon(Icons.image_not_supported_outlined),
+                  ),
+                ),
+              ),
+            ),
+          ],
+        );
+      },
+    );
+  }
+}
+
+class _PinPainter extends CustomPainter {
+  final Color primaryColor;
+  final Color secondaryColor;
+  final double primaryRadius;
+  final double secondaryRadius;
+
+  _PinPainter({
+    this.primaryColor = Colors.black,
+    this.secondaryColor = Colors.white,
+    required this.primaryRadius,
+    required this.secondaryRadius,
+  });
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    Paint primaryBrush = Paint()
+      ..color = primaryColor
+      ..style = PaintingStyle.fill;
+
+    Paint secondaryBrush = Paint()
+      ..color = secondaryColor
+      ..style = PaintingStyle.fill;
+
+    Paint lineBrush = Paint()
+      ..color = primaryColor
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = 2;
+
+    canvas.drawCircle(
+      Offset(size.width / 2, size.height),
+      primaryRadius,
+      primaryBrush,
+    );
+    canvas.drawCircle(
+      Offset(size.width / 2, size.height),
+      secondaryRadius,
+      secondaryBrush,
+    );
+    canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
+    // The line is to make the above triangluar path more prominent since it has a slight curve
+    canvas.drawLine(
+      Offset(size.width / 2, 0),
+      Offset(
+        size.width / 2,
+        size.height,
+      ),
+      lineBrush,
+    );
+  }
+
+  Path getTrianglePath(double x, double y) {
+    final firstEndPoint = Offset(x / 2, y);
+    final controlPoint = Offset(x / 2, y * 0.3);
+    final secondEndPoint = Offset(x, 0);
+
+    return Path()
+      ..quadraticBezierTo(
+        controlPoint.dx,
+        controlPoint.dy,
+        firstEndPoint.dx,
+        firstEndPoint.dy,
+      )
+      ..quadraticBezierTo(
+        controlPoint.dx,
+        controlPoint.dy,
+        secondEndPoint.dx,
+        secondEndPoint.dy,
+      )
+      ..lineTo(0, 0);
+  }
+
+  @override
+  bool shouldRepaint(_PinPainter old) {
+    return old.primaryColor != primaryColor ||
+        old.secondaryColor != secondaryColor;
+  }
+}

+ 30 - 0
mobile/lib/modules/map/ui/location_dialog.dart

@@ -0,0 +1,30 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:geolocator/geolocator.dart';
+import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
+
+class LocationServiceDisabledDialog extends ConfirmDialog {
+  LocationServiceDisabledDialog({Key? key})
+      : super(
+          key: key,
+          title: 'map_location_service_disabled_title'.tr(),
+          content: 'map_location_service_disabled_content'.tr(),
+          cancel: 'map_location_dialog_cancel'.tr(),
+          ok: 'map_location_dialog_yes'.tr(),
+          onOk: () async {
+            await Geolocator.openLocationSettings();
+          },
+        );
+}
+
+class LocationPermissionDisabledDialog extends ConfirmDialog {
+  LocationPermissionDisabledDialog({Key? key})
+      : super(
+          key: key,
+          title: 'map_no_location_permission_title'.tr(),
+          content: 'map_no_location_permission_content'.tr(),
+          cancel: 'map_location_dialog_cancel'.tr(),
+          ok: 'map_location_dialog_yes'.tr(),
+          onOk: () {},
+        );
+}

+ 138 - 0
mobile/lib/modules/map/ui/map_page_app_bar.dart

@@ -0,0 +1,138 @@
+import 'dart:io';
+
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/disable_multi_select_button.dart';
+import 'package:immich_mobile/modules/map/ui/map_settings_dialog.dart';
+
+class MapAppBar extends HookWidget implements PreferredSizeWidget {
+  final ValueNotifier<bool> selectionEnabled;
+  final int selectedAssetsLength;
+  final bool isDarkTheme;
+
+  final void Function() onShare;
+  final void Function() onFavorite;
+  final void Function() onArchive;
+
+  const MapAppBar({
+    super.key,
+    required this.selectionEnabled,
+    required this.selectedAssetsLength,
+    required this.onShare,
+    required this.onArchive,
+    required this.onFavorite,
+    this.isDarkTheme = false,
+  });
+
+  List<Widget> buildNonSelectionWidgets(BuildContext context) {
+    return [
+      Padding(
+        padding: const EdgeInsets.only(left: 15, top: 15),
+        child: ElevatedButton(
+          onPressed: () => AutoRouter.of(context).pop(),
+          style: ElevatedButton.styleFrom(
+            shape: const CircleBorder(),
+            padding: const EdgeInsets.all(12),
+          ),
+          child: const Icon(Icons.arrow_back_ios_new_rounded, size: 22),
+        ),
+      ),
+      Padding(
+        padding: const EdgeInsets.only(right: 15, top: 15),
+        child: ElevatedButton(
+          onPressed: () => showDialog(
+            context: context,
+            builder: (BuildContext _) {
+              return const MapSettingsDialog();
+            },
+          ),
+          style: ElevatedButton.styleFrom(
+            shape: const CircleBorder(),
+            padding: const EdgeInsets.all(12),
+          ),
+          child: const Icon(Icons.more_vert_rounded, size: 22),
+        ),
+      ),
+    ];
+  }
+
+  List<Widget> buildSelectionWidgets() {
+    return [
+      DisableMultiSelectButton(
+        onPressed: () {
+          selectionEnabled.value = false;
+        },
+        selectedItemCount: selectedAssetsLength,
+      ),
+      Row(
+        children: [
+          // Share button
+          Padding(
+            padding: const EdgeInsets.only(top: 15),
+            child: ElevatedButton(
+              onPressed: onShare,
+              style: ElevatedButton.styleFrom(
+                shape: const CircleBorder(),
+                padding: const EdgeInsets.all(12),
+              ),
+              child: Icon(
+                Platform.isAndroid
+                    ? Icons.share_rounded
+                    : Icons.ios_share_rounded,
+                size: 22,
+              ),
+            ),
+          ),
+          // Favorite button
+          Padding(
+            padding: const EdgeInsets.only(top: 15),
+            child: ElevatedButton(
+              onPressed: onFavorite,
+              style: ElevatedButton.styleFrom(
+                shape: const CircleBorder(),
+                padding: const EdgeInsets.all(12),
+              ),
+              child: const Icon(
+                Icons.favorite,
+                size: 22,
+              ),
+            ),
+          ),
+          // Archive Button
+          Padding(
+            padding: const EdgeInsets.only(right: 10, top: 15),
+            child: ElevatedButton(
+              onPressed: onArchive,
+              style: ElevatedButton.styleFrom(
+                shape: const CircleBorder(),
+                padding: const EdgeInsets.all(12),
+              ),
+              child: const Icon(
+                Icons.archive,
+                size: 22,
+              ),
+            ),
+          ),
+        ],
+      ),
+    ];
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.only(top: 30),
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        children: [
+          if (!selectionEnabled.value) ...buildNonSelectionWidgets(context),
+          if (selectionEnabled.value) ...buildSelectionWidgets(),
+        ],
+      ),
+    );
+  }
+
+  @override
+  Size get preferredSize => const Size.fromHeight(100);
+}

+ 356 - 0
mobile/lib/modules/map/ui/map_page_bottom_sheet.dart

@@ -0,0 +1,356 @@
+import 'dart:async';
+
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
+import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/ui/drag_sheet.dart';
+import 'package:immich_mobile/utils/color_filter_generator.dart';
+import 'package:immich_mobile/utils/debounce.dart';
+import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+class MapPageBottomSheet extends StatefulHookConsumerWidget {
+  final Stream mapPageEventStream;
+  final StreamController bottomSheetEventSC;
+  final bool selectionEnabled;
+  final ImmichAssetGridSelectionListener selectionlistener;
+  final bool isDarkTheme;
+
+  const MapPageBottomSheet({
+    super.key,
+    required this.mapPageEventStream,
+    required this.bottomSheetEventSC,
+    required this.selectionEnabled,
+    required this.selectionlistener,
+    this.isDarkTheme = false,
+  });
+
+  @override
+  AssetsInBoundBottomSheetState createState() =>
+      AssetsInBoundBottomSheetState();
+}
+
+class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
+  // Non-State variables
+  bool userTappedOnMap = false;
+  RenderList? _cachedRenderList;
+  int lastAssetOffsetInSheet = -1;
+  late final DraggableScrollableController bottomSheetController;
+  late final Debounce debounce;
+
+  @override
+  void initState() {
+    super.initState();
+    bottomSheetController = DraggableScrollableController();
+    debounce = Debounce(
+      const Duration(milliseconds: 200),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    var isDarkMode = Theme.of(context).brightness == Brightness.dark;
+    double maxHeight = MediaQuery.of(context).size.height;
+    final isSheetScrolled = useState(false);
+    final isSheetExpanded = useState(false);
+    final assetsInBound = useState(<Asset>[]);
+    final currentExtend = useState(0.1);
+
+    void handleMapPageEvents(dynamic event) {
+      if (event is MapPageAssetsInBoundUpdated) {
+        assetsInBound.value = event.assets;
+      } else if (event is MapPageOnTapEvent) {
+        userTappedOnMap = true;
+        lastAssetOffsetInSheet = -1;
+        bottomSheetController.animateTo(
+          0.1,
+          duration: const Duration(milliseconds: 200),
+          curve: Curves.linearToEaseOut,
+        );
+        isSheetScrolled.value = false;
+      }
+    }
+
+    useEffect(
+      () {
+        final mapPageEventSubscription =
+            widget.mapPageEventStream.listen(handleMapPageEvents);
+        return mapPageEventSubscription.cancel;
+      },
+      [widget.mapPageEventStream],
+    );
+
+    void handleVisibleItems(ItemPosition start, ItemPosition end) {
+      final renderElement = _cachedRenderList?.elements[start.index];
+      if (renderElement == null) {
+        return;
+      }
+      final rowOffset = renderElement.offset;
+      if ((-start.itemLeadingEdge) != 0) {
+        var columnOffset = -start.itemLeadingEdge ~/ 0.05;
+        columnOffset = columnOffset < renderElement.totalCount
+            ? columnOffset
+            : renderElement.totalCount - 1;
+        lastAssetOffsetInSheet = rowOffset + columnOffset;
+        final asset = _cachedRenderList?.allAssets?[lastAssetOffsetInSheet];
+        userTappedOnMap = false;
+        if (!userTappedOnMap && isSheetExpanded.value) {
+          widget.bottomSheetEventSC.add(
+            MapPageBottomSheetScrolled(asset),
+          );
+        }
+        if (isSheetExpanded.value) {
+          isSheetScrolled.value = true;
+        }
+      }
+    }
+
+    void visibleItemsListener(ItemPosition start, ItemPosition end) {
+      if (_cachedRenderList == null) {
+        debounce.dispose();
+        return;
+      }
+      debounce.call(() => handleVisibleItems(start, end));
+    }
+
+    Widget buildNoPhotosWidget() {
+      const image = Image(
+        image: AssetImage('assets/lighthouse.png'),
+      );
+
+      return isSheetExpanded.value
+          ? Column(
+              children: [
+                const SizedBox(
+                  height: 80,
+                ),
+                SizedBox(
+                  height: 150,
+                  width: 150,
+                  child: isDarkMode
+                      ? const InvertionFilter(
+                          child: SaturationFilter(
+                            saturation: -1,
+                            child: BrightnessFilter(
+                              brightness: -5,
+                              child: image,
+                            ),
+                          ),
+                        )
+                      : image,
+                ),
+                const SizedBox(
+                  height: 20,
+                ),
+                Text(
+                  "map_zoom_to_see_photos".tr(),
+                  style: TextStyle(
+                    fontSize: 20,
+                    color: Theme.of(context).textTheme.displayLarge?.color,
+                  ),
+                ),
+              ],
+            )
+          : const SizedBox.shrink();
+    }
+
+    void onTapMapButton() {
+      if (lastAssetOffsetInSheet != -1) {
+        widget.bottomSheetEventSC.add(
+          MapPageZoomToAsset(
+            _cachedRenderList?.allAssets?[lastAssetOffsetInSheet],
+          ),
+        );
+      }
+    }
+
+    Widget buildDragHandle(ScrollController scrollController) {
+      final textToDisplay = assetsInBound.value.isNotEmpty
+          ? "${assetsInBound.value.length} photo${assetsInBound.value.length > 1 ? "s" : ""}"
+          : "map_no_assets_in_bounds".tr();
+      final dragHandle = Container(
+        height: 75,
+        width: double.infinity,
+        decoration: BoxDecoration(
+          color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
+        ),
+        child: Stack(
+          children: [
+            Column(
+              crossAxisAlignment: CrossAxisAlignment.center,
+              mainAxisAlignment: MainAxisAlignment.center,
+              children: [
+                const SizedBox(height: 12),
+                const CustomDraggingHandle(),
+                const SizedBox(height: 12),
+                Text(
+                  textToDisplay,
+                  style: TextStyle(
+                    fontSize: 16,
+                    color: Theme.of(context).textTheme.displayLarge?.color,
+                    fontWeight: FontWeight.bold,
+                  ),
+                ),
+                Divider(
+                  color: Theme.of(context)
+                      .textTheme
+                      .displayLarge
+                      ?.color
+                      ?.withOpacity(0.5),
+                ),
+              ],
+            ),
+            if (isSheetExpanded.value && isSheetScrolled.value)
+              Positioned(
+                top: 5,
+                right: 10,
+                child: IconButton(
+                  icon: Icon(
+                    Icons.map_outlined,
+                    color: Theme.of(context).textTheme.displayLarge?.color,
+                  ),
+                  iconSize: 20,
+                  tooltip: 'Zoom to bounds',
+                  onPressed: onTapMapButton,
+                ),
+              ),
+          ],
+        ),
+      );
+      return SingleChildScrollView(
+        controller: scrollController,
+        child: dragHandle,
+      );
+    }
+
+    return NotificationListener<DraggableScrollableNotification>(
+      onNotification: (DraggableScrollableNotification notification) {
+        final sheetExtended = notification.extent > 0.2;
+        isSheetExpanded.value = sheetExtended;
+        currentExtend.value = notification.extent;
+        if (!sheetExtended) {
+          // reset state
+          userTappedOnMap = false;
+          lastAssetOffsetInSheet = -1;
+          isSheetScrolled.value = false;
+        }
+
+        return true;
+      },
+      child: Stack(
+        children: [
+          DraggableScrollableSheet(
+            controller: bottomSheetController,
+            initialChildSize: 0.1,
+            minChildSize: 0.1,
+            maxChildSize: 0.55,
+            snap: true,
+            builder: (
+              BuildContext context,
+              ScrollController scrollController,
+            ) {
+              return Card(
+                color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
+                surfaceTintColor: Colors.transparent,
+                elevation: 18.0,
+                margin: const EdgeInsets.all(0),
+                child: Column(
+                  children: [
+                    buildDragHandle(scrollController),
+                    if (isSheetExpanded.value && assetsInBound.value.isNotEmpty)
+                      ref
+                          .watch(
+                            renderListProvider(
+                              assetsInBound.value,
+                            ),
+                          )
+                          .when(
+                            data: (renderList) {
+                              _cachedRenderList = renderList;
+                              final assetGrid = ImmichAssetGrid(
+                                shrinkWrap: true,
+                                renderList: renderList,
+                                showDragScroll: false,
+                                selectionActive: widget.selectionEnabled,
+                                showMultiSelectIndicator: false,
+                                listener: widget.selectionlistener,
+                                visibleItemsListener: visibleItemsListener,
+                              );
+
+                              return Expanded(child: assetGrid);
+                            },
+                            error: (error, stackTrace) {
+                              log.warning(
+                                "Cannot get assets in the current map bounds ${error.toString()}",
+                                error,
+                                stackTrace,
+                              );
+                              return const SizedBox.shrink();
+                            },
+                            loading: () => const SizedBox.shrink(),
+                          ),
+                    if (isSheetExpanded.value && assetsInBound.value.isEmpty)
+                      Expanded(
+                        child: SingleChildScrollView(
+                          child: buildNoPhotosWidget(),
+                        ),
+                      ),
+                  ],
+                ),
+              );
+            },
+          ),
+          Positioned(
+            bottom: maxHeight * currentExtend.value,
+            left: 0,
+            child: GestureDetector(
+              onTap: () => launchUrl(
+                Uri.parse('https://openstreetmap.org/copyright'),
+              ),
+              child: ColoredBox(
+                color:
+                    (widget.isDarkTheme ? Colors.grey[900] : Colors.grey[100])!,
+                child: Padding(
+                  padding: const EdgeInsets.all(3),
+                  child: Text(
+                    '© OpenStreetMap contributors',
+                    style: TextStyle(
+                      fontSize: 6,
+                      color: !widget.isDarkTheme
+                          ? Colors.grey[900]
+                          : Colors.grey[100],
+                    ),
+                  ),
+                ),
+              ),
+            ),
+          ),
+          Positioned(
+            bottom: maxHeight * (0.14 + (currentExtend.value - 0.1)),
+            right: 15,
+            child: ElevatedButton(
+              onPressed: () =>
+                  widget.bottomSheetEventSC.add(const MapPageZoomToLocation()),
+              style: ElevatedButton.styleFrom(
+                shape: const CircleBorder(),
+                padding: const EdgeInsets.all(12),
+              ),
+              child: const Icon(
+                Icons.my_location,
+                size: 22,
+                fill: 1,
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 193 - 0
mobile/lib/modules/map/ui/map_settings_dialog.dart

@@ -0,0 +1,193 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
+
+class MapSettingsDialog extends HookConsumerWidget {
+  const MapSettingsDialog({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final mapSettingsNotifier = ref.read(mapStateNotifier.notifier);
+    final mapSettings = ref.read(mapStateNotifier);
+    final isDarkMode = useState(mapSettings.isDarkTheme);
+    final showFavoriteOnly = useState(mapSettings.showFavoriteOnly);
+    final showRelativeDate = useState(mapSettings.relativeTime);
+    final ThemeData theme = Theme.of(context);
+
+    Widget buildMapThemeSetting() {
+      return SwitchListTile.adaptive(
+        value: isDarkMode.value,
+        onChanged: (value) {
+          isDarkMode.value = value;
+        },
+        activeColor: theme.primaryColor,
+        dense: true,
+        title: Text(
+          "map_settings_dark_mode".tr(),
+          style:
+              theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
+        ),
+      );
+    }
+
+    Widget buildFavoriteOnlySetting() {
+      return SwitchListTile.adaptive(
+        value: showFavoriteOnly.value,
+        onChanged: (value) {
+          showFavoriteOnly.value = value;
+        },
+        activeColor: theme.primaryColor,
+        dense: true,
+        title: Text(
+          "map_settings_only_show_favorites".tr(),
+          style:
+              theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
+        ),
+      );
+    }
+
+    Widget buildDateRangeSetting() {
+      final now = DateTime.now();
+      return DropdownMenu(
+        enableSearch: false,
+        enableFilter: false,
+        initialSelection: showRelativeDate.value,
+        onSelected: (value) {
+          showRelativeDate.value = value!;
+        },
+        dropdownMenuEntries: [
+          const DropdownMenuEntry(value: 0, label: "All"),
+          const DropdownMenuEntry(
+            value: 1,
+            label: "Past 24 hours",
+          ),
+          const DropdownMenuEntry(
+            value: 7,
+            label: "Past 7 days",
+          ),
+          const DropdownMenuEntry(
+            value: 30,
+            label: "Past 30 days",
+          ),
+          DropdownMenuEntry(
+            value: now
+                .difference(
+                  DateTime(
+                    now.year - 1,
+                    now.month,
+                    now.day,
+                    now.hour,
+                    now.minute,
+                    now.second,
+                  ),
+                )
+                .inDays,
+            label: "Past year",
+          ),
+          DropdownMenuEntry(
+            value: now
+                .difference(
+                  DateTime(
+                    now.year - 3,
+                    now.month,
+                    now.day,
+                    now.hour,
+                    now.minute,
+                    now.second,
+                  ),
+                )
+                .inDays,
+            label: "Past 3 years",
+          ),
+        ],
+      );
+    }
+
+    List<Widget> getDialogActions() {
+      return <Widget>[
+        TextButton(
+          onPressed: () => Navigator.of(context).pop(),
+          style: TextButton.styleFrom(
+            backgroundColor:
+                mapSettings.isDarkTheme ? Colors.grey[100] : Colors.grey[700],
+          ),
+          child: Text(
+            "map_settings_dialog_cancel".tr(),
+            style: theme.textTheme.labelSmall?.copyWith(
+              fontWeight: FontWeight.bold,
+              color:
+                  mapSettings.isDarkTheme ? Colors.grey[900] : Colors.grey[100],
+            ),
+          ),
+        ),
+        TextButton(
+          onPressed: () {
+            mapSettingsNotifier.switchTheme(isDarkMode.value);
+            mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value);
+            mapSettingsNotifier.setRelativeTime(showRelativeDate.value);
+            Navigator.of(context).pop();
+          },
+          style: TextButton.styleFrom(
+            backgroundColor: theme.primaryColor,
+          ),
+          child: Text(
+            "map_settings_dialog_save".tr(),
+            style: theme.textTheme.labelSmall?.copyWith(
+              fontWeight: FontWeight.bold,
+              color: theme.primaryTextTheme.labelLarge?.color,
+            ),
+          ),
+        ),
+      ];
+    }
+
+    return AlertDialog(
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
+      title: Center(
+        child: Text(
+          "map_settings_dialog_title".tr(),
+          style: TextStyle(
+            color: theme.primaryColor,
+            fontWeight: FontWeight.bold,
+            fontSize: 18,
+          ),
+        ),
+      ),
+      content: SizedBox(
+        width: double.maxFinite,
+        child: ConstrainedBox(
+          constraints: BoxConstraints(
+            maxHeight: MediaQuery.of(context).size.height * 0.6,
+          ),
+          child: ListView(
+            shrinkWrap: true,
+            children: [
+              buildMapThemeSetting(),
+              buildFavoriteOnlySetting(),
+              const SizedBox(
+                height: 10,
+              ),
+              Padding(
+                padding: const EdgeInsets.only(left: 20),
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Text(
+                      "map_settings_only_relative_range".tr(),
+                      style: const TextStyle(fontWeight: FontWeight.bold),
+                    ),
+                    buildDateRangeSetting(),
+                  ],
+                ),
+              ),
+            ].toList(),
+          ),
+        ),
+      ),
+      actions: getDialogActions(),
+      actionsAlignment: MainAxisAlignment.spaceEvenly,
+    );
+  }
+}

+ 76 - 0
mobile/lib/modules/map/ui/map_thumbnail.dart

@@ -0,0 +1,76 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_map/plugin_api.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/utils/color_filter_generator.dart';
+import 'package:latlong2/latlong.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+// A non-interactive thumbnail of a map in the given coordinates with optional markers
+class MapThumbnail extends HookConsumerWidget {
+  final Function(TapPosition, LatLng)? onTap;
+  final LatLng coords;
+  final double zoom;
+  final List<Marker> markers;
+  final double height;
+  final bool showAttribution;
+  final bool isDarkTheme;
+
+  const MapThumbnail({
+    super.key,
+    required this.coords,
+    required this.height,
+    this.onTap,
+    this.zoom = 1,
+    this.showAttribution = true,
+    this.isDarkTheme = false,
+    this.markers = const [],
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final tileLayer = TileLayer(
+      urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
+      subdomains: const ['a', 'b', 'c'],
+    );
+
+    return SizedBox(
+      height: height,
+      child: ClipRRect(
+        borderRadius: const BorderRadius.all(Radius.circular(15)),
+        child: FlutterMap(
+          options: MapOptions(
+            interactiveFlags: InteractiveFlag.none,
+            center: coords,
+            zoom: zoom,
+            onTap: onTap,
+          ),
+          nonRotatedChildren: [
+            if (showAttribution)
+              RichAttributionWidget(
+                animationConfig: const ScaleRAWA(),
+                attributions: [
+                  TextSourceAttribution(
+                    'OpenStreetMap contributors',
+                    onTap: () => launchUrl(
+                      Uri.parse('https://openstreetmap.org/copyright'),
+                    ),
+                  ),
+                ],
+              ),
+          ],
+          children: [
+            isDarkTheme
+                ? InvertionFilter(
+                    child: SaturationFilter(
+                      saturation: -1,
+                      child: tileLayer,
+                    ),
+                  )
+                : tileLayer,
+            if (markers.isNotEmpty) MarkerLayer(markers: markers),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 499 - 0
mobile/lib/modules/map/views/map_page.dart

@@ -0,0 +1,499 @@
+import 'dart:async';
+
+import 'package:auto_route/auto_route.dart';
+import 'package:collection/collection.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_map/plugin_api.dart';
+import 'package:flutter_map_heatmap/flutter_map_heatmap.dart';
+import 'package:fluttertoast/fluttertoast.dart';
+import 'package:geolocator/geolocator.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
+import 'package:immich_mobile/modules/map/providers/map_marker.provider.dart';
+import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
+import 'package:immich_mobile/modules/map/ui/asset_marker_icon.dart';
+import 'package:immich_mobile/modules/map/ui/location_dialog.dart';
+import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart';
+import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:immich_mobile/utils/color_filter_generator.dart';
+import 'package:immich_mobile/utils/debounce.dart';
+import 'package:immich_mobile/utils/flutter_map_extensions.dart';
+import 'package:immich_mobile/utils/immich_app_theme.dart';
+import 'package:immich_mobile/utils/selection_handlers.dart';
+import 'package:latlong2/latlong.dart';
+import 'package:logging/logging.dart';
+
+class MapPage extends StatefulHookConsumerWidget {
+  const MapPage({super.key});
+
+  @override
+  MapPageState createState() => MapPageState();
+}
+
+class MapPageState extends ConsumerState<MapPage> {
+  // Non-State variables
+  late final MapController mapController;
+  // Streams are used instead of callbacks to prevent unnecessary rebuilds on events
+  final StreamController mapPageEventSC =
+      StreamController<MapPageEventBase>.broadcast();
+  final StreamController bottomSheetEventSC =
+      StreamController<MapPageEventBase>.broadcast();
+  late final Stream bottomSheetEventStream;
+  // Making assets in bounds as a state variable will result in un-necessary rebuilds of the bottom sheet
+  // resulting in it getting reloaded each time a map move occurs
+  Set<AssetMarkerData> assetsInBounds = {};
+  // TODO: Migrate the handling to MapEventMove#id when flutter_map is upgraded
+  // https://github.com/fleaflet/flutter_map/issues/1542
+  // The below is used instead of MapEventMove#id to handle event from controller
+  // in onMapEvent() since MapEventMove#id is not populated properly in the
+  // current version of flutter_map(4.0.0) used
+  bool forceAssetUpdate = false;
+  late final Debounce debounce;
+
+  @override
+  void initState() {
+    super.initState();
+    mapController = MapController();
+    bottomSheetEventStream = bottomSheetEventSC.stream;
+    // Map zoom events and move events are triggered often. Throttle the call to limit rebuilds
+    debounce = Debounce(
+      const Duration(milliseconds: 300),
+    );
+  }
+
+  @override
+  void dispose() {
+    debounce.dispose();
+    super.dispose();
+  }
+
+  void reloadAssetsInBound(
+    Set<AssetMarkerData>? assetMarkers, {
+    bool forceReload = false,
+  }) {
+    final bounds = mapController.bounds;
+    if (bounds != null) {
+      final oldAssetsInBounds = assetsInBounds.toSet();
+      assetsInBounds =
+          assetMarkers?.where((e) => bounds.contains(e.point)).toSet() ?? {};
+      final shouldReload = forceReload ||
+          assetsInBounds.difference(oldAssetsInBounds).isNotEmpty ||
+          assetsInBounds.length != oldAssetsInBounds.length;
+      if (shouldReload) {
+        mapPageEventSC.add(
+          MapPageAssetsInBoundUpdated(
+            assetsInBounds.map((e) => e.asset).toList(),
+          ),
+        );
+      }
+    }
+  }
+
+  void openAssetInViewer(Asset asset) {
+    AutoRouter.of(context).push(
+      GalleryViewerRoute(
+        initialIndex: 0,
+        loadAsset: (index) => asset,
+        totalAssets: 1,
+        heroOffset: 0,
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final log = Logger("MapService");
+    final isDarkTheme =
+        ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
+    final ValueNotifier<Set<AssetMarkerData>> mapMarkerData =
+        useState(<AssetMarkerData>{});
+    final ValueNotifier<AssetMarkerData?> closestAssetMarker = useState(null);
+    final selectionEnabledHook = useState(false);
+    final selectedAssets = useState(<Asset>{});
+    final showLoadingIndicator = useState(false);
+    final refetchMarkers = useState(true);
+
+    if (refetchMarkers.value) {
+      mapMarkerData.value = ref.watch(mapMarkersProvider).when(
+            skipLoadingOnRefresh: false,
+            error: (error, stackTrace) {
+              log.warning(
+                "Cannot get map markers ${error.toString()}",
+                error,
+                stackTrace,
+              );
+              showLoadingIndicator.value = false;
+              return {};
+            },
+            loading: () {
+              showLoadingIndicator.value = true;
+              return {};
+            },
+            data: (data) {
+              showLoadingIndicator.value = false;
+              refetchMarkers.value = false;
+              closestAssetMarker.value = null;
+              debounce(
+                () => reloadAssetsInBound(
+                  mapMarkerData.value,
+                  forceReload: true,
+                ),
+              );
+              return data;
+            },
+          );
+    }
+
+    ref.listen(mapStateNotifier, (previous, next) {
+      bool shouldRefetch =
+          previous?.showFavoriteOnly != next.showFavoriteOnly ||
+              previous?.relativeTime != next.relativeTime;
+      if (shouldRefetch) {
+        refetchMarkers.value = shouldRefetch;
+        ref.invalidate(mapMarkersProvider);
+      }
+    });
+
+    void onZoomToAssetEvent(Asset? assetInBottomSheet) {
+      if (assetInBottomSheet != null) {
+        final mapMarker = mapMarkerData.value
+            .firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
+        if (mapMarker != null) {
+          LatLng? newCenter = mapController.centerBoundsWithPadding(
+            mapMarker.point,
+            const Offset(0, -120),
+            zoomLevel: 6,
+          );
+          if (newCenter != null) {
+            forceAssetUpdate = true;
+            mapController.move(newCenter, 6);
+          }
+        }
+      }
+    }
+
+    void onZoomToLocation() async {
+      try {
+        bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
+        if (!serviceEnabled) {
+          showDialog(
+            context: context,
+            builder: (context) => Theme(
+              data: isDarkTheme ? immichDarkTheme : immichLightTheme,
+              child: LocationServiceDisabledDialog(),
+            ),
+          );
+          return;
+        }
+
+        LocationPermission permission = await Geolocator.checkPermission();
+        bool shouldRequestPermission = false;
+
+        if (permission == LocationPermission.denied) {
+          shouldRequestPermission = await showDialog(
+            context: context,
+            builder: (context) => Theme(
+              data: isDarkTheme ? immichDarkTheme : immichLightTheme,
+              child: LocationPermissionDisabledDialog(),
+            ),
+          );
+          if (shouldRequestPermission) {
+            permission = await Geolocator.requestPermission();
+          }
+        }
+
+        if (permission == LocationPermission.denied ||
+            permission == LocationPermission.deniedForever) {
+          // Open app settings only if you did not request for permission before
+          if (permission == LocationPermission.deniedForever &&
+              !shouldRequestPermission) {
+            await Geolocator.openAppSettings();
+          }
+          return;
+        }
+
+        Position currentUserLocation = await Geolocator.getCurrentPosition(
+          desiredAccuracy: LocationAccuracy.medium,
+          timeLimit: const Duration(seconds: 5),
+        );
+
+        forceAssetUpdate = true;
+        mapController.move(
+          LatLng(currentUserLocation.latitude, currentUserLocation.longitude),
+          12,
+        );
+      } catch (error) {
+        log.severe(
+          "Cannot get user's current location due to ${error.toString()}",
+        );
+        if (context.mounted) {
+          ImmichToast.show(
+            context: context,
+            gravity: ToastGravity.BOTTOM,
+            toastType: ToastType.error,
+            msg: "map_cannot_get_user_location".tr(),
+          );
+        }
+      }
+    }
+
+    void handleBottomSheetEvents(dynamic event) {
+      if (event is MapPageBottomSheetScrolled) {
+        final assetInBottomSheet = event.asset;
+        if (assetInBottomSheet != null) {
+          final mapMarker = mapMarkerData.value
+              .firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
+          closestAssetMarker.value = mapMarker;
+          if (mapMarker != null && mapController.zoom >= 5) {
+            LatLng? newCenter = mapController.centerBoundsWithPadding(
+              mapMarker.point,
+              const Offset(0, -120),
+            );
+            if (newCenter != null) {
+              mapController.move(
+                newCenter,
+                mapController.zoom,
+              );
+            }
+          }
+        }
+      } else if (event is MapPageZoomToAsset) {
+        onZoomToAssetEvent(event.asset);
+      } else if (event is MapPageZoomToLocation) {
+        onZoomToLocation();
+      }
+    }
+
+    useEffect(
+      () {
+        final bottomSheetEventSubscription =
+            bottomSheetEventStream.listen(handleBottomSheetEvents);
+        return bottomSheetEventSubscription.cancel;
+      },
+      [bottomSheetEventStream],
+    );
+
+    void handleMapTapEvent(LatLng tapPosition) {
+      const d = Distance();
+      final assetsInBoundsList = assetsInBounds.toList();
+      assetsInBoundsList.sort(
+        (a, b) => d
+            .distance(a.point, tapPosition)
+            .compareTo(d.distance(b.point, tapPosition)),
+      );
+      // First asset less than the threshold from the tap point
+      final nearestAsset = assetsInBoundsList.firstWhereOrNull(
+        (element) =>
+            d.distance(element.point, tapPosition) <
+            mapController.getTapThresholdForZoomLevel(),
+      );
+      // Reset marker if no assets are near the tap point
+      if (nearestAsset == null && closestAssetMarker.value != null) {
+        selectionEnabledHook.value = false;
+        mapPageEventSC.add(
+          const MapPageOnTapEvent(),
+        );
+      }
+      closestAssetMarker.value = nearestAsset;
+    }
+
+    void onMapEvent(MapEvent mapEvent) {
+      if (mapEvent is MapEventMove || mapEvent is MapEventDoubleTapZoom) {
+        if (forceAssetUpdate ||
+            mapEvent.source != MapEventSource.mapController) {
+          debounce(() {
+            if (selectionEnabledHook.value) {
+              selectionEnabledHook.value = false;
+            }
+            reloadAssetsInBound(
+              mapMarkerData.value,
+              forceReload: forceAssetUpdate,
+            );
+            forceAssetUpdate = false;
+          });
+        }
+      } else if (mapEvent is MapEventTap) {
+        handleMapTapEvent(mapEvent.tapPosition);
+      }
+    }
+
+    void onShareAsset() {
+      handleShareAssets(ref, context, selectedAssets.value.toList());
+      selectionEnabledHook.value = false;
+    }
+
+    void onFavoriteAsset() async {
+      showLoadingIndicator.value = true;
+      try {
+        await handleFavoriteAssets(ref, context, selectedAssets.value.toList());
+      } finally {
+        showLoadingIndicator.value = false;
+        selectionEnabledHook.value = false;
+        refetchMarkers.value = true;
+      }
+    }
+
+    void onArchiveAsset() async {
+      showLoadingIndicator.value = true;
+      try {
+        await handleArchiveAssets(ref, context, selectedAssets.value.toList());
+      } finally {
+        showLoadingIndicator.value = false;
+        selectionEnabledHook.value = false;
+        refetchMarkers.value = true;
+      }
+    }
+
+    void selectionListener(bool isMultiSelect, Set<Asset> selection) {
+      selectionEnabledHook.value = isMultiSelect;
+      selectedAssets.value = selection;
+    }
+
+    final tileLayer = TileLayer(
+      urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
+      subdomains: const ['a', 'b', 'c'],
+      maxNativeZoom: 19,
+      maxZoom: 19,
+    );
+
+    final darkTileLayer = InvertionFilter(
+      child: SaturationFilter(
+        saturation: -1,
+        child: BrightnessFilter(
+          brightness: -1,
+          child: tileLayer,
+        ),
+      ),
+    );
+
+    final markerLayer = MarkerLayer(
+      markers: [
+        if (closestAssetMarker.value != null)
+          AssetMarker(
+            remoteId: closestAssetMarker.value!.asset.remoteId!,
+            anchorPos: AnchorPos.align(AnchorAlign.top),
+            point: closestAssetMarker.value!.point,
+            width: 100,
+            height: 100,
+            builder: (ctx) => GestureDetector(
+              onTap: () => openAssetInViewer(closestAssetMarker.value!.asset),
+              child: AssetMarkerIcon(
+                isDarkTheme: isDarkTheme,
+                id: closestAssetMarker.value!.asset.remoteId!,
+              ),
+            ),
+          ),
+      ],
+    );
+
+    final heatMapLayer = mapMarkerData.value.isNotEmpty
+        ? HeatMapLayer(
+            heatMapDataSource: InMemoryHeatMapDataSource(
+              data: mapMarkerData.value
+                  .map(
+                    (e) => WeightedLatLng(
+                      LatLng(e.point.latitude, e.point.longitude),
+                      1,
+                    ),
+                  )
+                  .toList(),
+            ),
+            heatMapOptions: HeatMapOptions(
+              radius: 60,
+              layerOpacity: 0.5,
+              gradient: {
+                0.20: Colors.deepPurple,
+                0.40: Colors.blue,
+                0.60: Colors.green,
+                0.95: Colors.yellow,
+                1.0: Colors.deepOrange,
+              },
+            ),
+          )
+        : const SizedBox.shrink();
+
+    return AnnotatedRegion<SystemUiOverlayStyle>(
+      value: SystemUiOverlayStyle(
+        statusBarColor: Colors.black.withOpacity(0.5),
+        statusBarIconBrightness: Brightness.light,
+      ),
+      child: Theme(
+        // Override app theme based on map theme
+        data: isDarkTheme ? immichDarkTheme : immichLightTheme,
+        child: Scaffold(
+          appBar: MapAppBar(
+            isDarkTheme: isDarkTheme,
+            selectionEnabled: selectionEnabledHook,
+            selectedAssetsLength: selectedAssets.value.length,
+            onShare: onShareAsset,
+            onArchive: onArchiveAsset,
+            onFavorite: onFavoriteAsset,
+          ),
+          extendBodyBehindAppBar: true,
+          body: Stack(
+            children: [
+              FlutterMap(
+                mapController: mapController,
+                options: MapOptions(
+                  maxBounds:
+                      LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
+                  interactiveFlags: InteractiveFlag.doubleTapZoom |
+                      InteractiveFlag.drag |
+                      InteractiveFlag.flingAnimation |
+                      InteractiveFlag.pinchMove |
+                      InteractiveFlag.pinchZoom,
+                  center: LatLng(20, 20),
+                  zoom: 2,
+                  minZoom: 1,
+                  maxZoom: 18, // max level supported by OSM,
+                  onMapReady: () {
+                    mapController.mapEventStream.listen(onMapEvent);
+                  },
+                ),
+                children: [
+                  isDarkTheme ? darkTileLayer : tileLayer,
+                  heatMapLayer,
+                  markerLayer,
+                ],
+              ),
+              MapPageBottomSheet(
+                mapPageEventStream: mapPageEventSC.stream,
+                bottomSheetEventSC: bottomSheetEventSC,
+                selectionEnabled: selectionEnabledHook.value,
+                selectionlistener: selectionListener,
+                isDarkTheme: isDarkTheme,
+              ),
+              if (showLoadingIndicator.value)
+                Positioned(
+                  top: MediaQuery.of(context).size.height * 0.35,
+                  left: MediaQuery.of(context).size.width * 0.425,
+                  child: const ImmichLoadingIndicator(),
+                ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+class AssetMarker extends Marker {
+  String remoteId;
+
+  AssetMarker({
+    super.key,
+    required this.remoteId,
+    super.anchorPos,
+    required super.point,
+    super.width = 100.0,
+    super.height = 100.0,
+    required super.builder,
+  });
+}

+ 1 - 1
mobile/lib/modules/memories/ui/memory_card.dart

@@ -110,7 +110,7 @@ class MemoryCard extends HookConsumerWidget {
               left: 18.0,
               bottom: 18.0,
               child: buildTitle(),
-            )
+            ),
         ],
       ),
     );

+ 2 - 1
mobile/lib/modules/onboarding/views/permission_onboarding_page.dart

@@ -153,6 +153,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
         child = buildRequestPermission();
         break;
       case PermissionStatus.granted:
+      case PermissionStatus.provisional:
         child = buildPermissionGranted();
         break;
       case PermissionStatus.restricted:
@@ -183,7 +184,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
                 ),
                 TextButton(
                   child: const Text('permission_onboarding_log_out').tr(),
-                  onPressed: () { 
+                  onPressed: () {
                     ref.read(authenticationProvider.notifier).logout();
                     AutoRouter.of(context).replace(
                       const LoginRoute(),

+ 2 - 2
mobile/lib/modules/partner/views/partner_page.dart

@@ -44,7 +44,7 @@ class PartnerPage extends HookConsumerWidget {
                       Text("${u.firstName} ${u.lastName}"),
                     ],
                   ),
-                )
+                ),
             ],
           );
         },
@@ -151,7 +151,7 @@ class PartnerPage extends HookConsumerWidget {
                 availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
             icon: const Icon(Icons.person_add),
             tooltip: "partner_page_add_partner".tr(),
-          )
+          ),
         ],
       ),
       body: buildUserList(partners),

+ 2 - 2
mobile/lib/modules/search/ui/curated_people_row.dart

@@ -50,7 +50,7 @@ class CuratedPeopleRow extends StatelessWidget {
       itemBuilder: (context, index) {
         final person = content[index];
         final headers = {
-          "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
+          "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
         };
         return Padding(
           padding: const EdgeInsets.only(right: 18.0),
@@ -102,7 +102,7 @@ class CuratedPeopleRow extends StatelessWidget {
                         fontSize: 13.0,
                       ),
                     ),
-                  )
+                  ),
               ],
             ),
           ),

+ 110 - 0
mobile/lib/modules/search/ui/curated_places_row.dart

@@ -0,0 +1,110 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
+import 'package:immich_mobile/modules/search/ui/curated_row.dart';
+import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:latlong2/latlong.dart';
+
+class CuratedPlacesRow extends CuratedRow {
+  const CuratedPlacesRow({
+    super.key,
+    required super.content,
+    super.imageSize,
+    super.onTap,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    Widget buildMapThumbnail() {
+      return GestureDetector(
+        onTap: () => AutoRouter.of(context).push(
+          const MapRoute(),
+        ),
+        child: SizedBox(
+          height: imageSize,
+          width: imageSize,
+          child: Stack(
+            children: [
+              Padding(
+                padding: const EdgeInsets.only(right: 10.0),
+                child: MapThumbnail(
+                  zoom: 2,
+                  coords: LatLng(
+                    47,
+                    5,
+                  ),
+                  height: imageSize,
+                  showAttribution: false,
+                  isDarkTheme: Theme.of(context).brightness == Brightness.dark,
+                ),
+              ),
+              Container(
+                decoration: BoxDecoration(
+                  borderRadius: BorderRadius.circular(10),
+                  color: Colors.black,
+                  gradient: LinearGradient(
+                    begin: FractionalOffset.topCenter,
+                    end: FractionalOffset.bottomCenter,
+                    colors: [
+                      Colors.blueGrey.withOpacity(0.0),
+                      Colors.black.withOpacity(0.4),
+                    ],
+                    stops: const [0.0, 1.0],
+                  ),
+                ),
+              ),
+              const Align(
+                alignment: Alignment.bottomCenter,
+                child: Padding(
+                  padding: EdgeInsets.only(bottom: 10),
+                  child: Text(
+                    "Your Map",
+                    style: TextStyle(
+                      color: Colors.white,
+                      fontWeight: FontWeight.bold,
+                      fontSize: 14,
+                    ),
+                  ),
+                ),
+              ),
+            ],
+          ),
+        ),
+      );
+    }
+
+    return ListView.builder(
+      scrollDirection: Axis.horizontal,
+      padding: const EdgeInsets.symmetric(
+        horizontal: 16,
+      ),
+      itemBuilder: (context, index) {
+        // Injecting Map thumbnail as the first element
+        if (index == 0) {
+          return buildMapThumbnail();
+        }
+        // The actual index is 1 less than the virutal index since we inject map into the first position
+        final actualIndex = index - 1;
+        final object = content[actualIndex];
+        final thumbnailRequestUrl =
+            '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
+        return SizedBox(
+          width: imageSize,
+          height: imageSize,
+          child: Padding(
+            padding: const EdgeInsets.only(right: 10.0),
+            child: ThumbnailWithInfo(
+              imageUrl: thumbnailRequestUrl,
+              textInfo: object.label,
+              onTap: () => onTap?.call(object, actualIndex),
+            ),
+          ),
+        );
+      },
+      // Adding 1 to inject map thumbnail as first element
+      itemCount: content.length + 1,
+    );
+  }
+}

+ 1 - 1
mobile/lib/modules/search/ui/search_suggestion_list.dart

@@ -39,7 +39,7 @@ class SearchSuggestionList extends ConsumerWidget {
                               color: Theme.of(context).primaryColor,
                               fontWeight: FontWeight.bold,
                             ),
-                      )
+                      ),
                     ],
                   ),
                 ),

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio