Browse Source

merge main

martabal 1 year ago
parent
commit
8b7a4f2169
100 changed files with 4228 additions and 753 deletions
  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
      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
         uses: subosito/flutter-action@v2
         with:
         with:
           channel: "stable"
           channel: "stable"
-          flutter-version: "3.10.5"
+          flutter-version: "3.13.0"
           cache: true
           cache: true
 
 
       - name: Create the Keystore
       - name: Create the Keystore

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

@@ -42,7 +42,7 @@ jobs:
         uses: docker/setup-qemu-action@v2.2.0
         uses: docker/setup-qemu-action@v2.2.0
 
 
       - name: Set up Docker Buildx
       - 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:
         # Workaround to fix error:
         # failed to push: failed to copy: io: read/write on closed pipe
         # failed to push: failed to copy: io: read/write on closed pipe
         # See https://github.com/docker/build-push-action/issues/761
         # See https://github.com/docker/build-push-action/issues/761
@@ -126,7 +126,7 @@ jobs:
         uses: docker/setup-qemu-action@v2.2.0
         uses: docker/setup-qemu-action@v2.2.0
 
 
       - name: Set up Docker Buildx
       - 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:
         # Workaround to fix error:
         # failed to push: failed to copy: io: read/write on closed pipe
         # failed to push: failed to copy: io: read/write on closed pipe
         # See https://github.com/docker/build-push-action/issues/761
         # 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
         uses: subosito/flutter-action@v2
         with:
         with:
           channel: "stable"
           channel: "stable"
-          flutter-version: "3.10.5"
+          flutter-version: "3.13.0"
 
 
       - name: Install dependencies
       - name: Install dependencies
         run: dart pub get
         run: dart pub get

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

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

File diff suppressed because it is too large
+ 615 - 104
cli/src/api/open-api/api.ts


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

@@ -4,7 +4,7 @@
  * Immich
  * Immich
  * Immich API
  * 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).
  * 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
  * Immich API
  * 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).
  * 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
  * Immich API
  * 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).
  * 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
  * Immich API
  * 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).
  * 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 { LoginError } from '../cores/errors/login-error';
 import { exit } from 'node:process';
 import { exit } from 'node:process';
 import os from 'os';
 import os from 'os';
-import { ServerVersionReponseDto, UserResponseDto } from 'src/api/open-api';
+import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
 
 
 export abstract class BaseCommand {
 export abstract class BaseCommand {
   protected sessionService!: SessionService;
   protected sessionService!: SessionService;
   protected immichApi!: ImmichApi;
   protected immichApi!: ImmichApi;
   protected deviceId!: string;
   protected deviceId!: string;
   protected user!: UserResponseDto;
   protected user!: UserResponseDto;
-  protected serverVersion!: ServerVersionReponseDto;
+  protected serverVersion!: ServerVersionResponseDto;
 
 
   protected configDir;
   protected configDir;
   protected authPath;
   protected authPath;

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

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

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

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

+ 2 - 0
docker/docker-compose.yml

@@ -54,6 +54,8 @@ services:
     environment:
     environment:
       - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
       - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
       - TYPESENSE_DATA_DIR=/data
       - TYPESENSE_DATA_DIR=/data
+      # remove this to get debug messages
+      - GLOG_minloglevel=1
     volumes:
     volumes:
       - tsdata:/data
       - tsdata:/data
     restart: always
     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?
 ### 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?
 ### 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.
 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)?
 ### 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_WEB_URL=http://immich-web:3000
 IMMICH_SERVER_URL=http://immich-server:3001
 IMMICH_SERVER_URL=http://immich-server:3001
-IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
 
 
 ####################################################################################
 ####################################################################################
 # Alternative API's External Address - Optional
 # Alternative API's External Address - Optional

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

@@ -1,3 +1,7 @@
+---
+sidebar_position: 90
+---
+
 # Environment Variables
 # Environment Variables
 
 
 ## Docker Compose
 ## 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                        |
 | `LOG_LEVEL`                 | Log Level (verbose, debug, log, warn, error) |    `log`     | server, microservices                        |
 | `IMMICH_MEDIA_LOCATION`     | Media Location                               |  `./upload`  | server, microservices                        |
 | `IMMICH_MEDIA_LOCATION`     | Media Location                               |  `./upload`  | server, microservices                        |
 | `PUBLIC_LOGIN_PAGE_MESSAGE` | Public Login Page Message                    |              | web                                          |
 | `PUBLIC_LOGIN_PAGE_MESSAGE` | Public Login Page Message                    |              | web                                          |
+| `IMMICH_CONFIG_FILE`        | Path to config file                          |              | server                                       |
 
 
 :::tip
 :::tip
 
 
@@ -50,13 +55,14 @@ These environment variables are used by the `docker-compose.yml` file and do **N
 
 
 ## URLs
 ## 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
 :::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';
 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
 RUN python -m venv /opt/venv
 ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
 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 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
 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 pathlib import Path
 
 
 from pydantic import BaseSettings
 from pydantic import BaseSettings
@@ -8,25 +9,31 @@ from .schemas import ModelType
 class Settings(BaseSettings):
 class Settings(BaseSettings):
     cache_folder: str = "/cache"
     cache_folder: str = "/cache"
     classification_model: str = "microsoft/resnet-50"
     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"
     facial_recognition_model: str = "buffalo_l"
     min_tag_score: float = 0.9
     min_tag_score: float = 0.9
-    eager_startup: bool = True
+    eager_startup: bool = False
     model_ttl: int = 0
     model_ttl: int = 0
     host: str = "0.0.0.0"
     host: str = "0.0.0.0"
     port: int = 3003
     port: int = 3003
     workers: int = 1
     workers: int = 1
     min_face_score: float = 0.7
     min_face_score: float = 0.7
     test_full: bool = False
     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:
     class Config:
         env_prefix = "MACHINE_LEARNING_"
         env_prefix = "MACHINE_LEARNING_"
         case_sensitive = False
         case_sensitive = False
 
 
 
 
+_clean_name = str.maketrans(":\\/", "___", ".")
+
+
 def get_cache_dir(model_name: str, model_type: ModelType) -> Path:
 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()
 settings = Settings()

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

@@ -1,4 +1,6 @@
+import asyncio
 import os
 import os
+from concurrent.futures import ThreadPoolExecutor
 from io import BytesIO
 from io import BytesIO
 from typing import Any
 from typing import Any
 
 
@@ -8,6 +10,8 @@ import uvicorn
 from fastapi import Body, Depends, FastAPI
 from fastapi import Body, Depends, FastAPI
 from PIL import Image
 from PIL import Image
 
 
+from app.models.base import InferenceModel
+
 from .config import settings
 from .config import settings
 from .models.cache import ModelCache
 from .models.cache import ModelCache
 from .schemas import (
 from .schemas import (
@@ -25,19 +29,21 @@ app = FastAPI()
 
 
 def init_state() -> None:
 def init_state() -> None:
     app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=settings.model_ttl > 0)
     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:
 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
     # 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")
 @app.on_event("startup")
@@ -46,11 +52,16 @@ async def startup_event() -> None:
     await load_models()
     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:
 def dep_pil_image(byte_image: bytes = Body(...)) -> Image.Image:
     return Image.open(BytesIO(byte_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)
     byte_image_np = np.frombuffer(byte_image, np.uint8)
     return cv2.imdecode(byte_image_np, cv2.IMREAD_COLOR)
     return cv2.imdecode(byte_image_np, cv2.IMREAD_COLOR)
 
 
@@ -74,7 +85,7 @@ async def image_classification(
     image: Image.Image = Depends(dep_pil_image),
     image: Image.Image = Depends(dep_pil_image),
 ) -> list[str]:
 ) -> list[str]:
     model = await app.state.model_cache.get(settings.classification_model, ModelType.IMAGE_CLASSIFICATION)
     model = await app.state.model_cache.get(settings.classification_model, ModelType.IMAGE_CLASSIFICATION)
-    labels = model.predict(image)
+    labels = await predict(model, image)
     return labels
     return labels
 
 
 
 
@@ -86,8 +97,8 @@ async def image_classification(
 async def clip_encode_image(
 async def clip_encode_image(
     image: Image.Image = Depends(dep_pil_image),
     image: Image.Image = Depends(dep_pil_image),
 ) -> list[float]:
 ) -> 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
     return embedding
 
 
 
 
@@ -97,8 +108,8 @@ async def clip_encode_image(
     status_code=200,
     status_code=200,
 )
 )
 async def clip_encode_text(payload: TextModelRequest) -> list[float]:
 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
     return embedding
 
 
 
 
@@ -111,10 +122,14 @@ async def facial_recognition(
     image: cv2.Mat = Depends(dep_cv_image),
     image: cv2.Mat = Depends(dep_cv_image),
 ) -> list[dict[str, Any]]:
 ) -> list[dict[str, Any]]:
     model = await app.state.model_cache.get(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION)
     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
     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__":
 if __name__ == "__main__":
     is_dev = os.getenv("NODE_ENV") == "development"
     is_dev = os.getenv("NODE_ENV") == "development"
     uvicorn.run(
     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 .facial_recognition import FaceRecognizer
 from .image_classification import ImageClassifier
 from .image_classification import ImageClassifier

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

@@ -1,14 +1,17 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
+import os
+import pickle
 from abc import ABC, abstractmethod
 from abc import ABC, abstractmethod
 from pathlib import Path
 from pathlib import Path
 from shutil import rmtree
 from shutil import rmtree
 from typing import Any
 from typing import Any
 from zipfile import BadZipFile
 from zipfile import BadZipFile
 
 
+import onnxruntime as ort
 from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf  # type: ignore
 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
 from ..schemas import ModelType
 
 
 
 
@@ -16,12 +19,31 @@ class InferenceModel(ABC):
     _model_type: ModelType
     _model_type: ModelType
 
 
     def __init__(
     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:
     ) -> None:
         self.model_name = model_name
         self.model_name = model_name
         self._loaded = False
         self._loaded = False
         self._cache_dir = Path(cache_dir) if cache_dir is not None else get_cache_dir(model_name, self.model_type)
         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
         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:
         try:
             loader(**model_kwargs)
             loader(**model_kwargs)
         except (OSError, InvalidProtobuf, BadZipFile):
         except (OSError, InvalidProtobuf, BadZipFile):
@@ -30,6 +52,7 @@ class InferenceModel(ABC):
 
 
     def download(self, **model_kwargs: Any) -> None:
     def download(self, **model_kwargs: Any) -> None:
         if not self.cached:
         if not self.cached:
+            print(f"Downloading {self.model_type.value.replace('_', ' ')} model. This may take a while...")
             self._download(**model_kwargs)
             self._download(**model_kwargs)
 
 
     def load(self, **model_kwargs: Any) -> None:
     def load(self, **model_kwargs: Any) -> None:
@@ -39,6 +62,7 @@ class InferenceModel(ABC):
 
 
     def predict(self, inputs: Any) -> Any:
     def predict(self, inputs: Any) -> Any:
         if not self._loaded:
         if not self._loaded:
+            print(f"Loading {self.model_type.value.replace('_', ' ')} model...")
             self.load()
             self.load()
         return self._predict(inputs)
         return self._predict(inputs)
 
 
@@ -89,3 +113,14 @@ class InferenceModel(ABC):
         else:
         else:
             self.cache_dir.unlink()
             self.cache_dir.unlink()
         self.cache_dir.mkdir(parents=True, exist_ok=True)
         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.
             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:
         async with OptimisticLock(self.cache, key) as lock:
             model = await self.cache.get(key)
             model = await self.cache.get(key)
             if model is None:
             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 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 ..schemas import ModelType
 from .base import InferenceModel
 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
     _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:
     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:
     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]:
     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 cv2
 import numpy as np
 import numpy as np
+import onnxruntime as ort
 from insightface.model_zoo import ArcFaceONNX, RetinaFace
 from insightface.model_zoo import ArcFaceONNX, RetinaFace
 from insightface.utils.face_align import norm_crop
 from insightface.utils.face_align import norm_crop
 from insightface.utils.storage import BASE_REPO_URL, download_file
 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"))
             rec_file = next(self.cache_dir.glob("w600k_*.onnx"))
         except StopIteration:
         except StopIteration:
             raise FileNotFoundError("Facial recognition models not found in cache directory")
             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(
         self.det_model.prepare(
-            ctx_id=-1,
+            ctx_id=0,
             det_thresh=self.min_score,
             det_thresh=self.min_score,
             input_size=(640, 640),
             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]]:
     def _predict(self, image: cv2.Mat) -> list[dict[str, Any]]:
         bboxes, kpss = self.det_model.detect(image)
         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 typing import Any
 
 
 from huggingface_hub import snapshot_download
 from huggingface_hub import snapshot_download
+from optimum.onnxruntime import ORTModelForImageClassification
+from optimum.pipelines import pipeline
 from PIL.Image import Image
 from PIL.Image import Image
-from transformers.pipelines import pipeline
+from transformers import AutoImageProcessor
 
 
 from ..config import settings
 from ..config import settings
 from ..schemas import ModelType
 from ..schemas import ModelType
@@ -25,15 +27,34 @@ class ImageClassifier(InferenceModel):
 
 
     def _download(self, **model_kwargs: Any) -> None:
     def _download(self, **model_kwargs: Any) -> None:
         snapshot_download(
         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:
     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]:
     def _predict(self, image: Image) -> list[str]:
         predictions: list[dict[str, Any]] = self.model(image)  # type: ignore
         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 io import BytesIO
 from typing import TypeAlias
 from typing import TypeAlias
 from unittest import mock
 from unittest import mock
 
 
 import cv2
 import cv2
 import numpy as np
 import numpy as np
+import onnxruntime as ort
 import pytest
 import pytest
 from fastapi.testclient import TestClient
 from fastapi.testclient import TestClient
 from PIL import Image
 from PIL import Image
 from pytest_mock import MockerFixture
 from pytest_mock import MockerFixture
 
 
 from .config import settings
 from .config import settings
+from .models.base import PicklableSessionOptions
 from .models.cache import ModelCache
 from .models.cache import ModelCache
-from .models.clip import CLIPSTEncoder
+from .models.clip import CLIPEncoder
 from .models.facial_recognition import FaceRecognizer
 from .models.facial_recognition import FaceRecognizer
 from .models.image_classification import ImageClassifier
 from .models.image_classification import ImageClassifier
 from .schemas import ModelType
 from .schemas import ModelType
@@ -72,45 +75,47 @@ class TestCLIP:
     embedding = np.random.rand(512).astype(np.float32)
     embedding = np.random.rand(512).astype(np.float32)
 
 
     def test_eager_init(self, mocker: MockerFixture) -> None:
     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")
         mock_load.assert_called_once_with(test_arg="test_arg")
 
 
     def test_lazy_init(self, mocker: MockerFixture) -> None:
     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_download.assert_called_once_with(test_arg="test_arg")
         mock_load.assert_not_called()
         mock_load.assert_not_called()
 
 
     def test_basic_image(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
     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)
         embedding = clip_encoder.predict(pil_image)
 
 
         assert isinstance(embedding, list)
         assert isinstance(embedding, list)
         assert len(embedding) == 512
         assert len(embedding) == 512
         assert all([isinstance(num, float) for num in embedding])
         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:
     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")
         embedding = clip_encoder.predict("test search query")
 
 
         assert isinstance(embedding, list)
         assert isinstance(embedding, list)
         assert len(embedding) == 512
         assert len(embedding) == 512
         assert all([isinstance(num, float) for num in embedding])
         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:
 class TestFaceRecognition:
@@ -254,3 +259,13 @@ class TestEndpoints:
             headers=headers,
             headers=headers,
         )
         )
         assert response.status_code == 200
         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

File diff suppressed because it is too large
+ 710 - 168
machine-learning/poetry.lock


+ 20 - 5
machine-learning/pyproject.toml

@@ -1,6 +1,6 @@
 [tool.poetry]
 [tool.poetry]
 name = "machine-learning"
 name = "machine-learning"
-version = "1.73.0"
+version = "1.75.2"
 description = ""
 description = ""
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 readme = "README.md"
 readme = "README.md"
@@ -13,7 +13,6 @@ torch = [
     {markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.0.1", source = "pytorch-cpu"}
     {markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.0.1", source = "pytorch-cpu"}
 ]
 ]
 transformers = "^4.29.2"
 transformers = "^4.29.2"
-sentence-transformers = "^2.2.2"
 onnxruntime = "^1.15.0"
 onnxruntime = "^1.15.0"
 insightface = "^0.7.3"
 insightface = "^0.7.3"
 opencv-python-headless = "^4.7.0.72"
 opencv-python-headless = "^4.7.0.72"
@@ -22,6 +21,15 @@ fastapi = "^0.95.2"
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 pydantic = "^1.10.8"
 pydantic = "^1.10.8"
 aiocache = "^0.12.1"
 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]
 [tool.poetry.group.dev.dependencies]
 mypy = "^1.3.0"
 mypy = "^1.3.0"
@@ -62,13 +70,20 @@ warn_untyped_fields = true
 [[tool.mypy.overrides]]
 [[tool.mypy.overrides]]
 module = [
 module = [
     "huggingface_hub",
     "huggingface_hub",
-    "transformers.pipelines",
+    "transformers",
     "cv2",
     "cv2",
     "insightface.model_zoo",
     "insightface.model_zoo",
     "insightface.utils.face_align",
     "insightface.utils.face_align",
     "insightface.utils.storage",
     "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.backends.memory",
     "aiocache.lock",
     "aiocache.lock",
     "aiocache.plugins"
     "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": {}
   "flavors": {}
 }
 }

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

@@ -52,7 +52,7 @@ android {
     defaultConfig {
     defaultConfig {
         // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
         // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
         applicationId "app.alextran.immich"
         applicationId "app.alextran.immich"
-        minSdkVersion 23
+        minSdkVersion 26
         targetSdkVersion 33
         targetSdkVersion 33
         versionCode flutterVersionCode.toInteger()
         versionCode flutterVersionCode.toInteger()
         versionName flutterVersionName
         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.INTERNET" />
   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
   <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.WRITE_EXTERNAL_STORAGE" />
   <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
   <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
   <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
   <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.READ_MEDIA_AUDIO" />
   <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
   <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
   <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
   <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
 
 
   <queries>
   <queries>
     <intent>
     <intent>

+ 2 - 2
mobile/android/fastlane/Fastfile

@@ -35,8 +35,8 @@ platform :android do
       task: 'bundle', 
       task: 'bundle', 
       build_type: 'Release',
       build_type: 'Release',
       properties: {
       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')
     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_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_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_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
mobile/assets/lighthouse.png


+ 19 - 13
mobile/ios/Podfile.lock

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

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

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

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

@@ -83,8 +83,6 @@
     </dict>
     </dict>
     <key>NSCameraUsageDescription</key>
     <key>NSCameraUsageDescription</key>
     <string>We need to access the camera to let you take beautiful video using this app</string>
     <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>
     <key>NSLocationWhenInUseUsageDescription</key>
     <string>Enable location setting to show position of assets on map</string>
     <string>Enable location setting to show position of assets on map</string>
     <key>NSMicrophoneUsageDescription</key>
     <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.
 # The default execution directory of this script is the ci_scripts directory.
 cd $CI_WORKSPACE/mobile
 cd $CI_WORKSPACE/mobile

+ 1 - 1
mobile/ios/fastlane/Fastfile

@@ -19,7 +19,7 @@ platform :ios do
   desc "iOS Beta"
   desc "iOS Beta"
   lane :beta do
   lane :beta do
     increment_version_number(
     increment_version_number(
-      version_number: "1.73.0"
+      version_number: "1.75.2"
     )
     )
     increment_build_number(
     increment_build_number(
       build_number: latest_testflight_build_number + 1,
       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.onError = (details) {
     FlutterError.presentError(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) {
   PlatformDispatcher.instance.onError = (error, stack) {
-    log.severe(error.toString(), error, stack);
+    log.severe('Catch all error: ${error.toString()} - $error', error, stack);
     return true;
     return true;
   };
   };
 }
 }
@@ -139,6 +143,10 @@ class ImmichAppState extends ConsumerState<ImmichApp>
         debugPrint("[APP STATE] detached");
         debugPrint("[APP STATE] detached");
         ref.read(appStateProvider.notifier).handleAppDetached();
         ref.read(appStateProvider.notifier).handleAppDetached();
         break;
         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);
     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
   @override
   void dispose() {
   void dispose() {
     _streamSub.cancel();
     _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(
   Future<bool> changeTitleAlbum(
     Album album,
     Album album,
     String newAlbumTitle,
     String newAlbumTitle,

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

@@ -49,7 +49,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
           type: ThumbnailFormat.JPEG,
           type: ThumbnailFormat.JPEG,
         ),
         ),
         httpHeaders: {
         httpHeaders: {
-          "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
+          "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
         },
         },
         cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
         cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
         errorWidget: (context, url, error) =>
         errorWidget: (context, url, error) =>
@@ -105,9 +105,9 @@ class AlbumThumbnailListTile extends StatelessWidget {
                           style: TextStyle(
                           style: TextStyle(
                             fontSize: 12,
                             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),
           borderRadius: BorderRadius.circular(10),
         ),
         ),
         hintText: 'share_add_title'.tr(),
         hintText: 'share_add_title'.tr(),
+        hintStyle: TextStyle(
+          fontSize: 28,
+          color: isDarkTheme ? Colors.grey[300] : Colors.grey[700],
+          fontWeight: FontWeight.bold,
+        ),
         focusColor: Colors.grey[300],
         focusColor: Colors.grey[300],
         fillColor: isDarkTheme
         fillColor: isDarkTheme
             ? const Color.fromARGB(255, 32, 33, 35)
             ? 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 newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
     final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
     final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
 
 
-    void onDeleteAlbumPressed() async {
+    deleteAlbum() async {
       ImmichLoadingOverlayController.appLoader.show();
       ImmichLoadingOverlayController.appLoader.show();
 
 
       final bool success;
       final bool success;
@@ -65,6 +65,52 @@ class AlbumViewerAppbar extends HookConsumerWidget
       ImmichLoadingOverlayController.appLoader.hide();
       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 {
     void onLeaveAlbumPressed() async {
       ImmichLoadingOverlayController.appLoader.show();
       ImmichLoadingOverlayController.appLoader.show();
 
 
@@ -152,43 +198,61 @@ class AlbumViewerAppbar extends HookConsumerWidget
     }
     }
 
 
     void buildBottomSheet() {
     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(
       showModalBottomSheet(
         backgroundColor: Theme.of(context).scaffoldBackgroundColor,
         backgroundColor: Theme.of(context).scaffoldBackgroundColor,
         isScrollControlled: false,
         isScrollControlled: false,
         context: context,
         context: context,
         builder: (context) {
         builder: (context) {
           return SafeArea(
           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,
                 toastType: ToastType.error,
               );
               );
             }
             }
+
+            titleFocusNode.unfocus();
           },
           },
           icon: const Icon(Icons.check_rounded),
           icon: const Icon(Icons.check_rounded),
           splashRadius: 25,
           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],
             : Colors.grey[200],
         filled: titleFocusNode.hasFocus,
         filled: titleFocusNode.hasFocus,
         hintText: 'share_add_title'.tr(),
         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/album.dart';
 import 'package:immich_mobile/shared/models/asset.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_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 
 
 class AlbumViewerPage extends HookConsumerWidget {
 class AlbumViewerPage extends HookConsumerWidget {
@@ -116,7 +117,7 @@ class AlbumViewerPage extends HookConsumerWidget {
 
 
     Widget buildControlButton(Album album) {
     Widget buildControlButton(Album album) {
       return Padding(
       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(
         child: SizedBox(
           height: 40,
           height: 40,
           child: ListView(
           child: ListView(
@@ -141,7 +142,7 @@ class AlbumViewerPage extends HookConsumerWidget {
 
 
     Widget buildTitle(Album album) {
     Widget buildTitle(Album album) {
       return Padding(
       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
         child: userId == album.ownerId && album.isRemote
             ? AlbumViewerEditableTitle(
             ? AlbumViewerEditableTitle(
                 album: album,
                 album: album,
@@ -172,7 +173,6 @@ class AlbumViewerPage extends HookConsumerWidget {
       return Padding(
       return Padding(
         padding: EdgeInsets.only(
         padding: EdgeInsets.only(
           left: 16.0,
           left: 16.0,
-          top: 8.0,
           bottom: album.shared ? 0.0 : 8.0,
           bottom: album.shared ? 0.0 : 8.0,
         ),
         ),
         child: Text(
         child: Text(
@@ -180,7 +180,34 @@ class AlbumViewerPage extends HookConsumerWidget {
           style: const TextStyle(
           style: const TextStyle(
             fontSize: 14,
             fontSize: 14,
             fontWeight: FontWeight.bold,
             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: [
         children: [
           buildTitle(album),
           buildTitle(album),
           if (album.assets.isNotEmpty == true) buildAlbumDateRange(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)
                 AutoRouter.of(context)
                     .popForced<AssetSelectionPageResult>(payload);
                     .popForced<AssetSelectionPageResult>(payload);
               },
               },
-              child: const Text(
+              child: Text(
                 "share_add",
                 "share_add",
-                style: TextStyle(fontWeight: FontWeight.bold),
+                style: TextStyle(
+                  fontWeight: FontWeight.bold,
+                  color: Theme.of(context).primaryColor,
+                ),
               ).tr(),
               ).tr(),
             ),
             ),
         ],
         ],

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

@@ -30,7 +30,8 @@ class CreateAlbumPage extends HookConsumerWidget {
     final albumTitleTextFieldFocusNode = useFocusNode();
     final albumTitleTextFieldFocusNode = useFocusNode();
     final isAlbumTitleTextFieldFocus = useState(false);
     final isAlbumTitleTextFieldFocus = useState(false);
     final isAlbumTitleEmpty = useState(true);
     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;
     final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
 
 
     showSelectUserPage() async {
     showSelectUserPage() async {
@@ -248,8 +249,9 @@ class CreateAlbumPage extends HookConsumerWidget {
                   : null,
                   : null,
               child: Text(
               child: Text(
                 'create_shared_album_page_create'.tr(),
                 'create_shared_album_page_create'.tr(),
-                style: const TextStyle(
+                style: TextStyle(
                   fontWeight: FontWeight.bold,
                   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() {
     Widget buildSortButton() {
       final options = [
       final options = [
         "library_page_sort_created".tr(),
         "library_page_sort_created".tr(),
-        "library_page_sort_title".tr()
+        "library_page_sort_title".tr(),
       ];
       ];
 
 
       return PopupMenuButton(
       return PopupMenuButton(
@@ -87,7 +87,7 @@ class LibraryPage extends HookConsumerWidget {
                       color: selected ? Theme.of(context).primaryColor : null,
                       color: selected ? Theme.of(context).primaryColor : null,
                       fontSize: 12.0,
                       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/album.dart';
 import 'package:immich_mobile/shared/models/user.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/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 
 
 class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
 class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
   final Album album;
   final Album album;
@@ -35,10 +36,8 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
           ),
           ),
         );
         );
       } else {
       } 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 {
                   } else {
                     sharedUsersList.value = {
                     sharedUsersList.value = {
                       ...sharedUsersList.value,
                       ...sharedUsersList.value,
-                      users[index]
+                      users[index],
                     };
                     };
                   }
                   }
                 },
                 },
@@ -136,7 +135,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
               "share_add",
               "share_add",
               style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
               style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
             ).tr(),
             ).tr(),
-          )
+          ),
         ],
         ],
       ),
       ),
       body: suggestedShareUsers.when(
       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/asset.dart';
 import 'package:immich_mobile/shared/models/user.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/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
 
 
 class SelectUserForSharingPage extends HookConsumerWidget {
 class SelectUserForSharingPage extends HookConsumerWidget {
   const SelectUserForSharingPage({Key? key, required this.assets})
   const SelectUserForSharingPage({Key? key, required this.assets})
@@ -56,10 +57,8 @@ class SelectUserForSharingPage extends HookConsumerWidget {
           ),
           ),
         );
         );
       } else {
       } 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 {
                   } else {
                     sharedUsersList.value = {
                     sharedUsersList.value = {
                       ...sharedUsersList.value,
                       ...sharedUsersList.value,
-                      users[index]
+                      users[index],
                     };
                     };
                   }
                   }
                 },
                 },
@@ -164,7 +163,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
                 // color: Theme.of(context).primaryColor,
                 // color: Theme.of(context).primaryColor,
               ),
               ),
             ).tr(),
             ).tr(),
-          )
+          ),
         ],
         ],
       ),
       ),
       body: suggestedShareUsers.when(
       body: suggestedShareUsers.when(

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

@@ -160,7 +160,7 @@ class SharingPage extends HookConsumerWidget {
                   maxLines: 1,
                   maxLines: 1,
                 ).tr(),
                 ).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:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.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/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/shared/models/asset.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_loading_indicator.dart';
-import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:immich_mobile/utils/selection_handlers.dart';
 
 
 class ArchivePage extends HookConsumerWidget {
 class ArchivePage extends HookConsumerWidget {
   const ArchivePage({super.key});
   const ArchivePage({super.key});
@@ -68,30 +66,18 @@ class ArchivePage extends HookConsumerWidget {
                         : () async {
                         : () async {
                             processing.value = true;
                             processing.value = true;
                             try {
                             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 {
                             } finally {
                               processing.value = false;
                               processing.value = false;
                               selectionEnabledHook.value = false;
                               selectionEnabledHook.value = false;
                             }
                             }
                           },
                           },
-                  )
+                  ),
                 ],
                 ],
               ),
               ),
             ),
             ),
@@ -124,7 +110,7 @@ class ArchivePage extends HookConsumerWidget {
                   ),
                   ),
                   if (selectionEnabledHook.value) buildBottomBar(),
                   if (selectionEnabledHook.value) buildBottomBar(),
                   if (processing.value)
                   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:flutter_map/flutter_map.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/description_input.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/models/asset.dart';
 import 'package:immich_mobile/shared/ui/drag_sheet.dart';
 import 'package:immich_mobile/shared/ui/drag_sheet.dart';
 import 'package:latlong2/latlong.dart';
 import 'package:latlong2/latlong.dart';
@@ -16,16 +17,35 @@ class ExifBottomSheet extends HookConsumerWidget {
 
 
   const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
   const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
 
 
-  bool get showMap =>
+  bool get hasCoordinates =>
       asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null;
       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) {
     if (Platform.isAndroid) {
       Uri uri = Uri(
       Uri uri = Uri(
         scheme: 'geo',
         scheme: 'geo',
         host: '$latitude,$longitude',
         host: '$latitude,$longitude',
-        queryParameters: {'z': '$zoomLevel', 'q': '$latitude,$longitude'},
+        queryParameters: {
+          'z': '$zoomLevel',
+          'q': '$latitude,$longitude($formattedDateTime)',
+        },
       );
       );
       if (await canLaunchUrl(uri)) {
       if (await canLaunchUrl(uri)) {
         return uri;
         return uri;
@@ -33,16 +53,20 @@ class ExifBottomSheet extends HookConsumerWidget {
     } else if (Platform.isIOS) {
     } else if (Platform.isIOS) {
       var params = {
       var params = {
         'll': '$latitude,$longitude',
         'll': '$latitude,$longitude',
-        'q': '$latitude, $longitude',
+        'q': formattedDateTime,
+        'z': '$zoomLevel',
       };
       };
       Uri uri = Uri.https('maps.apple.com', '/', params);
       Uri uri = Uri.https('maps.apple.com', '/', params);
-      if (!await canLaunchUrl(uri)) {
+      if (await canLaunchUrl(uri)) {
         return 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),
         padding: const EdgeInsets.symmetric(vertical: 16.0),
         child: LayoutBuilder(
         child: LayoutBuilder(
           builder: (context, constraints) {
           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?.latitude ?? 0,
                     exifInfo?.longitude ?? 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() {
     buildLocation() {
       // Guard no lat/lng
       // Guard no lat/lng
-      if (!showMap) {
+      if (!hasCoordinates) {
         return Container();
         return Container();
       }
       }
 
 
@@ -199,7 +191,7 @@ class ExifBottomSheet extends HookConsumerWidget {
               Text(
               Text(
                 "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
                 "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
                 style: const TextStyle(fontSize: 12),
                 style: const TextStyle(fontSize: 12),
-              )
+              ),
             ],
             ],
           ),
           ),
         ],
         ],
@@ -207,12 +199,8 @@ class ExifBottomSheet extends HookConsumerWidget {
     }
     }
 
 
     buildDate() {
     buildDate() {
-      final fileCreatedAt = asset.fileCreatedAt.toLocal();
-      final date = DateFormat.yMMMEd().format(fileCreatedAt);
-      final time = DateFormat.jm().format(fileCreatedAt);
-
       return Text(
       return Text(
-        '$date • $time',
+        formattedDateTime,
         style: const TextStyle(
         style: const TextStyle(
           fontWeight: FontWeight.bold,
           fontWeight: FontWeight.bold,
           fontSize: 14,
           fontSize: 14,
@@ -306,7 +294,7 @@ class ExifBottomSheet extends HookConsumerWidget {
                           crossAxisAlignment: CrossAxisAlignment.start,
                           crossAxisAlignment: CrossAxisAlignment.start,
                           children: [
                           children: [
                             Flexible(
                             Flexible(
-                              flex: showMap ? 5 : 0,
+                              flex: hasCoordinates ? 5 : 0,
                               child: Padding(
                               child: Padding(
                                 padding: const EdgeInsets.only(right: 8.0),
                                 padding: const EdgeInsets.only(right: 8.0),
                                 child: buildLocation(),
                                 child: buildLocation(),
@@ -336,7 +324,7 @@ class ExifBottomSheet extends HookConsumerWidget {
                     if (asset.isRemote) DescriptionInput(asset: asset),
                     if (asset.isRemote) DescriptionInput(asset: asset),
                     const SizedBox(height: 8.0),
                     const SizedBox(height: 8.0),
                     buildLocation(),
                     buildLocation(),
-                    SizedBox(height: showMap ? 16.0 : 0.0),
+                    SizedBox(height: hasCoordinates ? 16.0 : 0.0),
                     buildDetail(),
                     buildDetail(),
                     const SizedBox(height: 50),
                     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.isLocal && !asset.isRemote) buildUploadButton(),
         if (asset.isRemote && !asset.isLocal) buildDownloadButton(),
         if (asset.isRemote && !asset.isLocal) buildDownloadButton(),
         if (asset.isRemote) buildAddToAlbumButtom(),
         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:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:video_player/video_player.dart';
 import 'package:video_player/video_player.dart';
-import 'package:wakelock/wakelock.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
 
 
 // ignore: must_be_immutable
 // ignore: must_be_immutable
 class VideoViewerPage extends HookConsumerWidget {
 class VideoViewerPage extends HookConsumerWidget {
@@ -136,16 +136,16 @@ class _VideoPlayerState extends State<VideoPlayer> {
     videoPlayerController.addListener(() {
     videoPlayerController.addListener(() {
       if (videoPlayerController.value.isInitialized) {
       if (videoPlayerController.value.isInitialized) {
         if (videoPlayerController.value.isPlaying) {
         if (videoPlayerController.value.isPlaying) {
-          Wakelock.enable();
+          WakelockPlus.enable();
           widget.onPlaying?.call();
           widget.onPlaying?.call();
         } else if (!videoPlayerController.value.isPlaying) {
         } else if (!videoPlayerController.value.isPlaying) {
-          Wakelock.disable();
+          WakelockPlus.disable();
           widget.onPaused?.call();
           widget.onPaused?.call();
         }
         }
 
 
         if (videoPlayerController.value.position ==
         if (videoPlayerController.value.position ==
             videoPlayerController.value.duration) {
             videoPlayerController.value.duration) {
-          Wakelock.disable();
+          WakelockPlus.disable();
           widget.onVideoEnded();
           widget.onVideoEnded();
         }
         }
       }
       }
@@ -155,8 +155,8 @@ class _VideoPlayerState extends State<VideoPlayer> {
   Future<void> initializePlayer() async {
   Future<void> initializePlayer() async {
     try {
     try {
       videoPlayerController = widget.file == null
       videoPlayerController = widget.file == null
-          ? VideoPlayerController.network(
-              widget.url!,
+          ? VideoPlayerController.networkUrl(
+              Uri.parse(widget.url!),
               httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
               httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
             )
             )
           : VideoPlayerController.file(widget.file!);
           : VideoPlayerController.file(widget.file!);
@@ -210,8 +210,7 @@ class _VideoPlayerState extends State<VideoPlayer> {
         child: Center(
         child: Center(
           child: Stack(
           child: Stack(
             children: [
             children: [
-              if (widget.placeholder != null)
-                widget.placeholder!,
+              if (widget.placeholder != null) widget.placeholder!,
               const Center(
               const Center(
                 child: ImmichLoadingIndicator(),
                 child: ImmichLoadingIndicator(),
               ),
               ),

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

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

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

@@ -511,7 +511,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
       state = state.copyWith(
       state = state.copyWith(
         selectedAlbumsBackupAssetsIds: {
         selectedAlbumsBackupAssetsIds: {
           ...state.selectedAlbumsBackupAssetsIds,
           ...state.selectedAlbumsBackupAssetsIds,
-          deviceAssetId
+          deviceAssetId,
         },
         },
         allAssetsInDatabase: [...state.allAssetsInDatabase, 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 {
   Future<bool> _startUpload(Iterable<Asset> allManualUploads) async {
+    bool hasErrors = false;
     try {
     try {
       _backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
       _backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
 
 
       if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
       if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
         await PhotoManager.clearFileCache();
         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) {
         if (allUploadAssets.isEmpty) {
           debugPrint("[_startUpload] No Assets to upload - Abort Process");
           debugPrint("[_startUpload] No Assets to upload - Abort Process");
@@ -213,7 +227,7 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
           '[_startUpload] Manual Upload Completed - success: ${state.successfulUploads},'
           '[_startUpload] Manual Upload Completed - success: ${state.successfulUploads},'
           ' failed: ${state.totalAssetsToUpload - state.successfulUploads}',
           ' failed: ${state.totalAssetsToUpload - state.successfulUploads}',
         );
         );
-        bool hasErrors = false;
+
         // User cancelled upload
         // User cancelled upload
         if (!ok && state.cancelToken.isCancelled) {
         if (!ok && state.cancelToken.isCancelled) {
           await _localNotificationService.showOrUpdateManualUploadStatus(
           await _localNotificationService.showOrUpdateManualUploadStatus(
@@ -237,32 +251,29 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
             presentBanner: true,
             presentBanner: true,
           );
           );
         }
         }
-
-        _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
-        _handleAppInActivity();
-        await _backupProvider.notifyBackgroundServiceCanRun();
-        return !hasErrors;
       } else {
       } else {
         openAppSettings();
         openAppSettings();
         debugPrint("[_startUpload] Do not have permission to the gallery");
         debugPrint("[_startUpload] Do not have permission to the gallery");
       }
       }
     } catch (e) {
     } catch (e) {
       debugPrint("ERROR _startUpload: ${e.toString()}");
       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() {
   void _handleAppInActivity() {
     final appState = ref.read(appStateProvider.notifier).getAppState();
     final appState = ref.read(appStateProvider.notifier).getAppState();
     // The app is currently in background. Perform the necessary cleanups which
     // The app is currently in background. Perform the necessary cleanups which
     // are on-hold for upload completion
     // 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();
       ref.read(appStateProvider.notifier).handleAppInactivity();
     }
     }
   }
   }

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

@@ -218,7 +218,18 @@ class BackupService {
     bool anyErrors = false;
     bool anyErrors = false;
     final List<String> duplicatedAssetIds = [];
     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 {
       try {
         if (entity.type == AssetType.video) {
         if (entity.type == AssetType.video) {
           file = await entity.originFile;
           file = await entity.originFile;
@@ -248,9 +259,10 @@ class BackupService {
 
 
           req.fields['deviceAssetId'] = entity.id;
           req.fields['deviceAssetId'] = entity.id;
           req.fields['deviceId'] = deviceId;
           req.fields['deviceId'] = deviceId;
-          req.fields['fileCreatedAt'] = entity.createDateTime.toIso8601String();
+          req.fields['fileCreatedAt'] =
+              entity.createDateTime.toUtc().toIso8601String();
           req.fields['fileModifiedAt'] =
           req.fields['fileModifiedAt'] =
-              entity.modifiedDateTime.toIso8601String();
+              entity.modifiedDateTime.toUtc().toIso8601String();
           req.fields['isFavorite'] = entity.isFavorite.toString();
           req.fields['isFavorite'] = entity.isFavorite.toString();
           req.fields['duration'] = entity.videoDuration.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,
                     bottom: 10,
                     right: 25,
                     right: 25,
                     child: buildSelectedTextBox(),
                     child: buildSelectedTextBox(),
-                  )
+                  ),
                 ],
                 ],
               ),
               ),
             ),
             ),
@@ -218,7 +218,7 @@ class AlbumInfoCard extends HookConsumerWidget {
                             }),
                             }),
                             future: albumInfo.assetCount,
                             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(
                   Text(
                     " ${uploadProgress.toStringAsFixed(0)}%",
                     " ${uploadProgress.toStringAsFixed(0)}%",
                     style: const TextStyle(fontSize: 12),
                     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(
                   child: Wrap(
                     children: [
                     children: [
                       ...buildSelectedAlbumNameChip(),
                       ...buildSelectedAlbumNameChip(),
-                      ...buildExcludedAlbumNameChip()
+                      ...buildExcludedAlbumNameChip(),
                     ],
                     ],
                   ),
                   ),
                 ),
                 ),
@@ -301,7 +301,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
                             .watch(backupProvider)
                             .watch(backupProvider)
                             .availableAlbums
                             .availableAlbums
                             .length
                             .length
-                            .toString()
+                            .toString(),
                       ],
                       ],
                     ),
                     ),
                     style: const TextStyle(
                     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:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:permission_handler/permission_handler.dart';
 import 'package:permission_handler/permission_handler.dart';
 import 'package:url_launcher/url_launcher.dart';
 import 'package:url_launcher/url_launcher.dart';
-import 'package:wakelock/wakelock.dart';
+import 'package:wakelock_plus/wakelock_plus.dart';
 
 
 class BackupControllerPage extends HookConsumerWidget {
 class BackupControllerPage extends HookConsumerWidget {
   const BackupControllerPage({Key? key}) : super(key: key);
   const BackupControllerPage({Key? key}) : super(key: key);
@@ -114,7 +114,7 @@ class BackupControllerPage extends HookConsumerWidget {
           );
           );
           return;
           return;
         }
         }
-        Wakelock.enable();
+        WakelockPlus.enable();
         const limit = 100;
         const limit = 100;
         final toDelete = await ref
         final toDelete = await ref
             .read(backupVerificationServiceProvider)
             .read(backupVerificationServiceProvider)
@@ -140,7 +140,7 @@ class BackupControllerPage extends HookConsumerWidget {
           );
           );
         }
         }
       } finally {
       } finally {
-        Wakelock.disable();
+        WakelockPlus.disable();
         checkInProgress.value = false;
         checkInProgress.value = false;
       }
       }
     }
     }
@@ -202,7 +202,7 @@ class BackupControllerPage extends HookConsumerWidget {
                 child: const Text('backup_controller_page_storage_format').tr(
                 child: const Text('backup_controller_page_storage_format').tr(
                   args: [
                   args: [
                     backupState.serverInfo.diskUse,
                     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),
                   style: TextStyle(fontSize: 12),
                 ).tr(),
                 ).tr(),
                 buildSelectedAlbumName(),
                 buildSelectedAlbumName(),
-                buildExcludedAlbumName()
+                buildExcludedAlbumName(),
               ],
               ],
             ),
             ),
           ),
           ),
@@ -776,7 +776,7 @@ class BackupControllerPage extends HookConsumerWidget {
             const Divider(),
             const Divider(),
             const CurrentUploadingAssetInfoBox(),
             const CurrentUploadingAssetInfoBox(),
             if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
             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:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/favorite/providers/favorite_provider.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/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/shared/models/asset.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 {
 class FavoritesPage extends HookConsumerWidget {
   const FavoritesPage({Key? key}) : super(key: key);
   const FavoritesPage({Key? key}) : super(key: key);
@@ -44,16 +42,11 @@ class FavoritesPage extends HookConsumerWidget {
     void unfavorite() async {
     void unfavorite() async {
       try {
       try {
         if (selection.value.isNotEmpty) {
         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 {
       } finally {
@@ -83,7 +76,7 @@ class FavoritesPage extends HookConsumerWidget {
                       style: TextStyle(fontSize: 14),
                       style: TextStyle(fontSize: 14),
                     ),
                     ),
                     onTap: processing.value ? null : unfavorite,
                     onTap: processing.value ? null : unfavorite,
-                  )
+                  ),
                 ],
                 ],
               ),
               ),
             ),
             ),
@@ -108,7 +101,7 @@ class FavoritesPage extends HookConsumerWidget {
                         selectionActive: selectionEnabledHook.value,
                         selectionActive: selectionEnabledHook.value,
                         listener: selectionListener,
                         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,
                     Icons.check_circle_outline_rounded,
                     color: Colors.grey,
                     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)?
   final void Function(ItemPosition start, ItemPosition end)?
       visibleItemsListener;
       visibleItemsListener;
   final Widget? topWidget;
   final Widget? topWidget;
+  final bool shrinkWrap;
+  final bool showDragScroll;
 
 
   const ImmichAssetGrid({
   const ImmichAssetGrid({
     super.key,
     super.key,
@@ -47,6 +49,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
     this.showMultiSelectIndicator = true,
     this.showMultiSelectIndicator = true,
     this.visibleItemsListener,
     this.visibleItemsListener,
     this.topWidget,
     this.topWidget,
+    this.shrinkWrap = false,
+    this.showDragScroll = true,
   });
   });
 
 
   @override
   @override
@@ -89,7 +93,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
                 perRow.value = 7 - scaleFactor.value.toInt();
                 perRow.value = 7 - scaleFactor.value.toInt();
               }
               }
             };
             };
-          })
+          }),
         },
         },
         child: ImmichAssetGridView(
         child: ImmichAssetGridView(
           onRefresh: onRefresh,
           onRefresh: onRefresh,
@@ -108,6 +112,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
           visibleItemsListener: visibleItemsListener,
           visibleItemsListener: visibleItemsListener,
           topWidget: topWidget,
           topWidget: topWidget,
           heroOffset: heroOffset(),
           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;
       visibleItemsListener;
   final Widget? topWidget;
   final Widget? topWidget;
   final int heroOffset;
   final int heroOffset;
+  final bool shrinkWrap;
+  final bool showDragScroll;
 
 
   const ImmichAssetGridView({
   const ImmichAssetGridView({
     super.key,
     super.key,
@@ -52,6 +54,8 @@ class ImmichAssetGridView extends StatefulWidget {
     this.visibleItemsListener,
     this.visibleItemsListener,
     this.topWidget,
     this.topWidget,
     this.heroOffset = 0,
     this.heroOffset = 0,
+    this.shrinkWrap = false,
+    this.showDragScroll = true,
   });
   });
 
 
   @override
   @override
@@ -225,7 +229,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
               right: i + 1 == num ? 0.0 : widget.margin,
               right: i + 1 == num ? 0.0 : widget.margin,
             ),
             ),
             color: Colors.grey,
             color: Colors.grey,
-          )
+          ),
       ],
       ],
     );
     );
   }
   }
@@ -300,7 +304,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
   }
   }
 
 
   Text _labelBuilder(int pos) {
   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(
     return Text(
       DateFormat.yMMMM().format(date),
       DateFormat.yMMMM().format(date),
       style: const TextStyle(
       style: const TextStyle(
@@ -318,7 +328,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
   }
   }
 
 
   Widget _buildAssetGrid() {
   Widget _buildAssetGrid() {
-    final useDragScrolling = widget.renderList.totalAssets >= 20;
+    final useDragScrolling =
+        widget.showDragScroll && widget.renderList.totalAssets >= 20;
 
 
     void dragScrolling(bool active) {
     void dragScrolling(bool active) {
       if (active != _scrolling) {
       if (active != _scrolling) {
@@ -335,8 +346,10 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
       itemBuilder: _itemBuilder,
       itemBuilder: _itemBuilder,
       itemPositionsListener: _itemPositionsListener,
       itemPositionsListener: _itemPositionsListener,
       itemScrollController: _itemScrollController,
       itemScrollController: _itemScrollController,
-      itemCount: widget.renderList.elements.length + (widget.topWidget != null ? 1 : 0),
+      itemCount: widget.renderList.elements.length +
+          (widget.topWidget != null ? 1 : 0),
       addRepaintBoundaries: true,
       addRepaintBoundaries: true,
+      shrinkWrap: widget.shrinkWrap,
     );
     );
 
 
     final child = useDragScrolling
     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)
               if (hasRemote)
                 const SliverToBoxAdapter(
                 const SliverToBoxAdapter(
                   child: SizedBox(height: 200),
                   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:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/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/models/authentication_state.model.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.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;
         backupState.backgroundBackup || backupState.autoBackup;
     final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
     final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
     AuthenticationState authState = ref.watch(authenticationProvider);
     AuthenticationState authState = ref.watch(authenticationProvider);
-
+    final user = Store.tryGet(StoreKey.currentUser);
     buildProfilePhoto() {
     buildProfilePhoto() {
-      if (authState.profileImagePath.isEmpty) {
+      if (authState.profileImagePath.isEmpty || user == null) {
         return IconButton(
         return IconButton(
           splashRadius: 25,
           splashRadius: 25,
           icon: const Icon(
           icon: const Icon(
@@ -47,9 +48,10 @@ class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
           onTap: () {
           onTap: () {
             Scaffold.of(context).openDrawer();
             Scaffold.of(context).openDrawer();
           },
           },
-          child: const UserCircleAvatar(
+          child: UserCircleAvatar(
             radius: 18,
             radius: 18,
             size: 33,
             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(),
               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:hooks_riverpod/hooks_riverpod.dart';
 import 'package:image_picker/image_picker.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/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/models/authentication_state.model.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@@ -19,14 +20,10 @@ class ProfileDrawerHeader extends HookConsumerWidget {
     final uploadProfileImageStatus =
     final uploadProfileImageStatus =
         ref.watch(uploadProfileImageProvider).status;
         ref.watch(uploadProfileImageProvider).status;
     final isDarkMode = Theme.of(context).brightness == Brightness.dark;
     final isDarkMode = Theme.of(context).brightness == Brightness.dark;
+    final user = Store.tryGet(StoreKey.currentUser);
 
 
     buildUserProfileImage() {
     buildUserProfileImage() {
-      var userImage = const UserCircleAvatar(
-        radius: 35,
-        size: 66,
-      );
-
-      if (authState.profileImagePath.isEmpty) {
+      if (authState.profileImagePath.isEmpty || user == null) {
         return const CircleAvatar(
         return const CircleAvatar(
           radius: 35,
           radius: 35,
           backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
           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 (uploadProfileImageStatus == UploadProfileStatus.idle) {
         if (authState.profileImagePath.isNotEmpty) {
         if (authState.profileImagePath.isNotEmpty) {
           return userImage;
           return userImage;
@@ -153,7 +156,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
           Text(
           Text(
             authState.userEmail,
             authState.userEmail,
             style: Theme.of(context).textTheme.labelMedium,
             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/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/user.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/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_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.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 {
 class HomePage extends HookConsumerWidget {
   const HomePage({Key? key}) : super(key: key);
   const HomePage({Key? key}) : super(key: key);
@@ -88,17 +87,7 @@ class HomePage extends HookConsumerWidget {
       }
       }
 
 
       void onShareAssets() {
       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;
         selectionEnabledHook.value = false;
       }
       }
@@ -126,16 +115,7 @@ class HomePage extends HookConsumerWidget {
             localErrorMessage: 'home_page_favorite_err_local'.tr(),
             localErrorMessage: 'home_page_favorite_err_local'.tr(),
           );
           );
           if (remoteAssets.isNotEmpty) {
           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 {
         } finally {
           processing.value = false;
           processing.value = false;
@@ -149,18 +129,7 @@ class HomePage extends HookConsumerWidget {
           final remoteAssets = remoteOnlySelection(
           final remoteAssets = remoteOnlySelection(
             localErrorMessage: 'home_page_archive_err_local'.tr(),
             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 {
         } finally {
           processing.value = false;
           processing.value = false;
           selectionEnabledHook.value = false;
           selectionEnabledHook.value = false;
@@ -221,7 +190,7 @@ class HomePage extends HookConsumerWidget {
                   namedArgs: {
                   namedArgs: {
                     "album": album.name,
                     "album": album.name,
                     "added": result.successfullyAdded.toString(),
                     "added": result.successfullyAdded.toString(),
-                    "failed": result.alreadyInAlbum.length.toString()
+                    "failed": result.alreadyInAlbum.length.toString(),
                   },
                   },
                 ),
                 ),
               );
               );
@@ -323,7 +292,7 @@ class HomePage extends HookConsumerWidget {
                     ).tr(),
                     ).tr(),
                   ),
                   ),
                 ),
                 ),
-              )
+              ),
             ],
             ],
           ),
           ),
         );
         );
@@ -365,7 +334,7 @@ class HomePage extends HookConsumerWidget {
                 enabled: !processing.value,
                 enabled: !processing.value,
                 selectionAssetState: selectionAssetState.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,
               left: 18.0,
               bottom: 18.0,
               bottom: 18.0,
               child: buildTitle(),
               child: buildTitle(),
-            )
+            ),
         ],
         ],
       ),
       ),
     );
     );

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

@@ -153,6 +153,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
         child = buildRequestPermission();
         child = buildRequestPermission();
         break;
         break;
       case PermissionStatus.granted:
       case PermissionStatus.granted:
+      case PermissionStatus.provisional:
         child = buildPermissionGranted();
         child = buildPermissionGranted();
         break;
         break;
       case PermissionStatus.restricted:
       case PermissionStatus.restricted:
@@ -183,7 +184,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
                 ),
                 ),
                 TextButton(
                 TextButton(
                   child: const Text('permission_onboarding_log_out').tr(),
                   child: const Text('permission_onboarding_log_out').tr(),
-                  onPressed: () { 
+                  onPressed: () {
                     ref.read(authenticationProvider.notifier).logout();
                     ref.read(authenticationProvider.notifier).logout();
                     AutoRouter.of(context).replace(
                     AutoRouter.of(context).replace(
                       const LoginRoute(),
                       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}"),
                       Text("${u.firstName} ${u.lastName}"),
                     ],
                     ],
                   ),
                   ),
-                )
+                ),
             ],
             ],
           );
           );
         },
         },
@@ -151,7 +151,7 @@ class PartnerPage extends HookConsumerWidget {
                 availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
                 availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
             icon: const Icon(Icons.person_add),
             icon: const Icon(Icons.person_add),
             tooltip: "partner_page_add_partner".tr(),
             tooltip: "partner_page_add_partner".tr(),
-          )
+          ),
         ],
         ],
       ),
       ),
       body: buildUserList(partners),
       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) {
       itemBuilder: (context, index) {
         final person = content[index];
         final person = content[index];
         final headers = {
         final headers = {
-          "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
+          "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
         };
         };
         return Padding(
         return Padding(
           padding: const EdgeInsets.only(right: 18.0),
           padding: const EdgeInsets.only(right: 18.0),
@@ -102,7 +102,7 @@ class CuratedPeopleRow extends StatelessWidget {
                         fontSize: 13.0,
                         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,
                               color: Theme.of(context).primaryColor,
                               fontWeight: FontWeight.bold,
                               fontWeight: FontWeight.bold,
                             ),
                             ),
-                      )
+                      ),
                     ],
                     ],
                   ),
                   ),
                 ),
                 ),

Some files were not shown because too many files changed in this diff