Browse Source

Merge branch 'main' of https://github.com/immich-app/immich into thumbhash_mobile

Luke McCarthy 2 years ago
parent
commit
38b945111c
100 changed files with 2960 additions and 911 deletions
  1. 3 0
      .github/workflows/prepare-release.yml
  2. 1 0
      README.md
  3. 104 0
      README_tr_TR.md
  4. 1 0
      README_zh_CN.md
  5. 0 0
      docs/blog/2022/11-10/release-1.36.mdx
  6. 105 0
      docs/blog/2023/06-24/update.mdx
  7. 1 0
      docs/docs/features/bulk-upload.md
  8. BIN
      docs/docs/features/img/me.png
  9. BIN
      docs/docs/features/img/my-wife.png
  10. 103 0
      docs/docs/features/read-only-gallery.md
  11. 5 0
      docs/docusaurus.config.js
  12. 2 2
      machine-learning/Dockerfile
  13. 9 0
      machine-learning/README.md
  14. 0 0
      machine-learning/app/__init__.py
  15. 10 1
      machine-learning/app/config.py
  16. 51 51
      machine-learning/app/main.py
  17. 0 119
      machine-learning/app/models.py
  18. 3 0
      machine-learning/app/models/__init__.py
  19. 52 0
      machine-learning/app/models/base.py
  20. 17 9
      machine-learning/app/models/cache.py
  21. 37 0
      machine-learning/app/models/clip.py
  22. 59 0
      machine-learning/app/models/facial_recognition.py
  23. 40 0
      machine-learning/app/models/image_classification.py
  24. 10 0
      machine-learning/app/schemas.py
  25. 24 0
      machine-learning/load_test.sh
  26. 52 0
      machine-learning/locustfile.py
  27. 776 137
      machine-learning/poetry.lock
  28. 3 1
      machine-learning/pyproject.toml
  29. 2 2
      mobile/android/fastlane/Fastfile
  30. 4 2
      mobile/assets/i18n/en-US.json
  31. 6 6
      mobile/ios/Podfile.lock
  32. 1 1
      mobile/ios/fastlane/Fastfile
  33. 21 0
      mobile/lib/modules/asset_viewer/providers/show_controls.provider.dart
  34. 46 0
      mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart
  35. 35 0
      mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart
  36. 57 0
      mobile/lib/modules/asset_viewer/ui/animated_play_pause.dart
  37. 53 0
      mobile/lib/modules/asset_viewer/ui/center_play_button.dart
  38. 207 0
      mobile/lib/modules/asset_viewer/ui/video_player_controls.dart
  39. 188 67
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  40. 7 1
      mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
  41. 11 0
      mobile/lib/modules/backup/services/backup.service.dart
  42. 2 2
      mobile/lib/modules/home/ui/asset_grid/group_divider_title.dart
  43. 3 0
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
  44. 7 3
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart
  45. 4 2
      mobile/lib/modules/login/services/oauth.service.dart
  46. 26 16
      mobile/lib/modules/onboarding/providers/gallery_permission.provider.dart
  47. 44 0
      mobile/lib/modules/search/providers/people.provider.dart
  48. 56 0
      mobile/lib/modules/search/services/person.service.dart
  49. 114 0
      mobile/lib/modules/search/ui/curated_people_row.dart
  50. 18 5
      mobile/lib/modules/search/ui/explore_grid.dart
  51. 82 0
      mobile/lib/modules/search/ui/person_name_edit_form.dart
  52. 46 0
      mobile/lib/modules/search/ui/search_row_title.dart
  53. 51 0
      mobile/lib/modules/search/views/all_people_page.dart
  54. 152 0
      mobile/lib/modules/search/views/person_result_page.dart
  55. 67 59
      mobile/lib/modules/search/views/search_page.dart
  56. 12 2
      mobile/lib/routing/router.dart
  57. 90 6
      mobile/lib/routing/router.gr.dart
  58. 2 0
      mobile/lib/routing/tab_navigation_observer.dart
  59. 2 0
      mobile/lib/shared/services/api.service.dart
  60. 8 4
      mobile/lib/shared/services/asset.service.dart
  61. 0 1
      mobile/lib/shared/views/tab_controller_page.dart
  62. 4 0
      mobile/lib/utils/image_url_builder.dart
  63. 48 0
      mobile/lib/utils/immich_app_theme.dart
  64. 1 1
      mobile/openapi/README.md
  65. 0 11
      mobile/openapi/lib/model/add_assets_dto.dart
  66. 1 12
      mobile/openapi/lib/model/add_assets_response_dto.dart
  67. 0 11
      mobile/openapi/lib/model/add_users_dto.dart
  68. 0 11
      mobile/openapi/lib/model/admin_signup_response_dto.dart
  69. 0 11
      mobile/openapi/lib/model/album_count_response_dto.dart
  70. 2 13
      mobile/openapi/lib/model/album_response_dto.dart
  71. 0 11
      mobile/openapi/lib/model/all_job_status_response_dto.dart
  72. 1 12
      mobile/openapi/lib/model/api_key_create_dto.dart
  73. 0 11
      mobile/openapi/lib/model/api_key_create_response_dto.dart
  74. 0 11
      mobile/openapi/lib/model/api_key_response_dto.dart
  75. 0 11
      mobile/openapi/lib/model/api_key_update_dto.dart
  76. 0 11
      mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart
  77. 0 11
      mobile/openapi/lib/model/asset_bulk_upload_check_item.dart
  78. 0 11
      mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart
  79. 2 13
      mobile/openapi/lib/model/asset_bulk_upload_check_result.dart
  80. 0 11
      mobile/openapi/lib/model/asset_count_by_time_bucket.dart
  81. 0 11
      mobile/openapi/lib/model/asset_count_by_time_bucket_response_dto.dart
  82. 0 11
      mobile/openapi/lib/model/asset_count_by_user_id_response_dto.dart
  83. 0 11
      mobile/openapi/lib/model/asset_file_upload_response_dto.dart
  84. 0 11
      mobile/openapi/lib/model/asset_ids_dto.dart
  85. 1 12
      mobile/openapi/lib/model/asset_ids_response_dto.dart
  86. 5 16
      mobile/openapi/lib/model/asset_response_dto.dart
  87. 0 11
      mobile/openapi/lib/model/auth_device_response_dto.dart
  88. 0 11
      mobile/openapi/lib/model/change_password_dto.dart
  89. 0 11
      mobile/openapi/lib/model/check_duplicate_asset_dto.dart
  90. 1 12
      mobile/openapi/lib/model/check_duplicate_asset_response_dto.dart
  91. 0 11
      mobile/openapi/lib/model/check_existing_assets_dto.dart
  92. 0 11
      mobile/openapi/lib/model/check_existing_assets_response_dto.dart
  93. 0 11
      mobile/openapi/lib/model/create_album_dto.dart
  94. 0 11
      mobile/openapi/lib/model/create_profile_image_response_dto.dart
  95. 0 11
      mobile/openapi/lib/model/create_tag_dto.dart
  96. 2 13
      mobile/openapi/lib/model/create_user_dto.dart
  97. 0 11
      mobile/openapi/lib/model/curated_locations_response_dto.dart
  98. 0 11
      mobile/openapi/lib/model/curated_objects_response_dto.dart
  99. 0 11
      mobile/openapi/lib/model/delete_asset_dto.dart
  100. 0 11
      mobile/openapi/lib/model/delete_asset_response_dto.dart

+ 3 - 0
.github/workflows/prepare-release.yml

@@ -34,6 +34,9 @@ jobs:
         with:
           token: ${{ secrets.ORG_RELEASE_TOKEN }}
 
+      - name: Install Poetry
+        run: pipx install poetry
+
       - name: Bump version
         run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
 

+ 1 - 0
README.md

@@ -19,6 +19,7 @@
 <br/>
 <p align="center">
   <a href="README_zh_CN.md">中文</a>
+  <a href="README_tr_TR.md">Türkçe</a>
 </p>
 
 ## Disclaimer

+ 104 - 0
README_tr_TR.md

@@ -0,0 +1,104 @@
+<p align="center"> 
+  <br/>  
+  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
+  <a href="https://discord.gg/D8JsnBEuKb">
+    <img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
+  </a>
+  <br/>  
+  <br/>   
+</p>
+
+<p align="center">
+<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
+</p>
+<h3 align="center">Immich - Yüksek performanslı, kendine ait barındırılan fotoğraf ve video yedekleme çözümü</h3>
+<br/>
+<a href="https://immich.app">
+<img src="design/immich-screenshots.png" title="Main Screenshot">
+</a>
+<br/>
+<p align="center">
+  <a href="README.md">English</a>
+  <a href="README_zh_CN.md">中文</a>
+</p>
+
+## Feragatname
+
+- ⚠️ Proje **çok aktif** bir şekilde geliştirilmektedir.
+- ⚠️ Hatalar ve uygulama yapısını bozan değişiklikler olabilir.
+- ⚠️ **Uygulamayı, fotoğraflarınızı ve videolarınızı saklamanın tek yöntemi olarak kullanmayın!**
+
+## Content
+
+- [Resmi Belgeler](https://immich.app/docs)
+- [Yol Haritası](https://github.com/orgs/immich-app/projects/1)
+- [Demo](#demo)
+- [Özellikler](#özellikler)
+- [Giriş](https://immich.app/docs/overview/introduction)
+- [Kurulum](https://immich.app/docs/install/requirements)
+- [Katkı Sağlama Rehberi](https://immich.app/docs/overview/support-the-project)
+- [Projeyi Destekle](#projeyi-destekle)
+
+## Belgeler
+
+Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz.
+
+## Demo
+
+Web demo adresi: https://demo.immich.app
+
+Mobil uygulama için `Server Endpoint URL` olarak `https://demo.immich.app/api` adresini kullanabilirsiniz.
+
+```bash title="Demo Bilgileri"
+Giriş bilgileri:
+email: demo@immich.app
+password: demo
+```
+
+```
+Server Özellikleri: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
+```
+
+# Özellikler
+
+| Özellikler                                          | Mobile | Web  |
+| ----------------------------------------------------| ------ | ---  |
+| Videoları ve fotoğrafları yükleme ve görüntüleme    | Evet   | Evet |
+| Uygulama açıldığında otomatik yedekleme             | Evet   | N/A  |
+| Yedekleme için seçilebilir albüm(ler)               | Evet   | N/A  |
+| Fotoğrafları ve videoları yerel cihaza yükleme      | Evet   | Evet |
+| Çoklu kullanıcı desteği                             | Evet   | Evet |
+| Albüm ve paylaşılan albümler                        | Evet   | Evet |
+| Silinebilir/sürüklenebilir kaydırma çubuğu          | Evet   | Evet |
+| RAW (HEIC, HEIF, DNG, Apple ProRaw) format desteği  | Evet   | Evet |
+| Metadata'ya uygun görüntüleme (EXIF, map)           | Evet   | Evet |
+| Metadata, objects, faces ve CLIP'e göre arama       | Evet   | Evet |
+| Yönetimsel işlevler (kullanıcı yönetimi)            | Hayır  | Evet |
+| Arka planda yedekleme                               | Evet   | N/A  |
+| Sanal kaydırma                                      | Evet   | Evet |
+| OAuth desteği                                       | Evet   | Evet |
+| API anahtarları                                     | N/A    | Evet |
+| LivePhoto yedekleme ve oynatma                      | iOS    | Evet |
+| Kullanıcı tanımlı depolama yapısı                   | Evet   | Evet |
+| Herkese açık paylaşım                               | Hayır  | Evet |
+| Arşiv ve Favoriler                                  | Evet   | Evet |
+| Dünya haritası                                      | Hayır  | Evet |
+| Partner paylaşımı                                   | Evet   | Evet |
+| Yüz tanıma ve kümeleme                              | Hayır  | Evet |
+| Çevrimdışı destek                                   | Evet   | Hayır|
+
+# Projeyi Destekle
+
+Bu projeye bağlı kaldım ve durmayacağım. Belgeleri güncellemeye, yeni özellikler eklemeye ve hataları düzeltmeye devam edeceğim. Ancak bunu tek başıma yapamam. Bu yüzden devam etme konusunda bana motivasyon sağlamanız için yardımınıza ihtiyacım var.
+
+[selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) bölümünde söylendiği üzere,bu projede takımımın ve benim projeye harcadağımız büyük bir çaba var. Bir gün bunu tam zamanlı olarak yapabilmeyi çok isterim. Bunu gerçekleştirebilmek için gerçekten sizlerin desteğine ihtiyacım var.
+
+Eğer bu size doğru bir amaç gibi geliyorsa ve uygulamanın uzun bir süre boyunca kullanacağınız bir şey olduğunu düşünüyorsanız, aşağıdaki bağlantılardan birini kullanarak bana destek olabilirsiniz.
+
+## Bağış
+
+- [Aylık bağış](https://github.com/sponsors/alextran1502) via GitHub Sponsors
+- [Bir seferlik bağış](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors
+- [Librepay](https://liberapay.com/alex.tran1502/)
+- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
+- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX

+ 1 - 0
README_zh_CN.md

@@ -23,6 +23,7 @@
 
 <p align="center">
   <a href="README.md">English</a>
+  <a href="README_tr_TR.md">Türkçe</a>
 </p>
 
 

+ 0 - 0
docs/blog/release-1.36/index.mdx → docs/blog/2022/11-10/release-1.36.mdx


+ 105 - 0
docs/blog/2023/06-24/update.mdx

@@ -0,0 +1,105 @@
+---
+title: June 2023 update
+authors: [alextran]
+tags: [update]
+---
+
+Hello everybody, Alex here!
+
+I am back with another update on Immich. It has been only a month since my last update (May 18th, 2023), but it seems forever. I think the rapid releases of Immich and the amount of work make the perspective of time change in Immich’s world. We have some exciting updates that I think you will like.
+
+Before going into detail, on behalf of the core team, I would like to thank all of you for loving Immich and contributing to the project. Thank you for helping me make Immich an enjoyable alternative solution to Google Photos so that you have complete control of your data and privacy. I know we are still young and have a lot of work to do, but I am confident we will get there with help from the community. I appreciate all of you from the bottom of my heart!
+
+<!--truncate-->
+
+And now, to the exciting part, what is new in Immich’s world?
+
+- Initial support for existing gallery.
+- Memory feature.
+- Support XMP sidecar.
+- Support more raw formats.
+- Justified layout for web timeline and blurred thumbnail hash.
+- Mechanism to host machine learning on a completely different machine.
+
+## Support for existing gallery
+
+I know this is the most controversial feature when it comes to Immich’s way of ingesting photos and videos. For many users, having to upload photos and videos to Immich is simply not working. We listen, discuss, and digest this feature internally more than you imagine because it is not a simple feature to tackle while keeping the performance and the user experience at the top level, which is Immich’s primary goal.
+
+Thankfully, we have many great contributors and developers that want to make this come true. So we came up with an initial implementation of this feature in the form of a supporting read-only gallery.
+
+To be concise, Immich can now read in the gallery files, register the path into the database, and then generate necessary files and put them through Immich’s machine learning pipeline so you can use all the goodness of Immich without the need to upload them. Since this is the initial implementation, some actions/behavior are not yet supported, and we aim to build toward them in future releases, namely:
+
+- Assets are not automatically synced and must instead be manually synced with the CLI tool.
+- Only new files that are added to the gallery will be detected.
+- Deleted and moved files will not be detected.
+
+You can find more information on how to use the feature by reading the documentation [here](/docs/features/read-only-gallery).
+
+## Memory feature
+
+This is considered a fun feature that the team and I wanted to build for so long, but we had to put it off because of the refactoring of the code base. The code base is now in a good enough form to circle back and add more exciting features.
+
+This memory feature is very much similar to GPhotos' implementation of “x years since…”. We are aiming to add more categories of memories in the future, such as “Spotlight of the day” or “Day of the Week highlights”
+
+<iframe
+  width="560"
+  height="315"
+  src="https://www.youtube.com/embed/j5XZKvViPew"
+  title="YouTube video player"
+  frameborder="0"
+  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+  allowfullscreen
+></iframe>
+
+This feature is now available on the web and will be ported to the mobile app in the near future.
+
+## Support XMP Sidecar
+
+Immich can now import/upload XMP sidecars from the CLI and use the information as the metadata of assets.
+
+## Support more raw formats.
+
+With the recent updates on the dependencies of Immich, we are now extending and hardening support for multiple raw formats. So users with DSLR or mirrorless cameras can now upload their original files to Immich and have them displayed in high-quality thumbnails on the web and mobile view.
+
+## Justified layout for web timeline and blurred thumbnail hash
+
+This is an aesthetic improvement in user experience when browsing the timeline. Photos and videos are now displayed correctly with perspective orientation, making the browsing experience more pleasurable.
+
+To further improve the browsing experience, we now added a blur hash to the thumbnail, so the transition is more natural with a dreamy fade in effect, similar to how our brain goes from faded to vivid memory
+
+<iframe
+  width="560"
+  height="315"
+  src="https://www.youtube.com/embed/b95FLmGHRFc"
+  title="YouTube video player"
+  frameborder="0"
+  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+  allowfullscreen
+></iframe>
+
+## Hosting machine learning container on a different machine
+
+With more capabilities Immich is building toward, machine learning will get more powerful and therefore require more resources to run effectively. However, we understand that users might not have the best server resources where they host the Immich instance. Therefore, we changed how machine learning interacts and receives the photos and videos to run through its inference pipeline.
+
+The machine learning container is now a headless system that can run on any machine. As long as your Immich instance can communicate with the system running the machine learning container, it can send the files and receive the required information to make Immich powerful in terms of searching and intelligence. This helps you to utilize a more powerful machine in your home/infrastructure to perform the CPU-intensive tasks while letting Immich only handle the I/O operations for a pleasant and smooth experience.
+
+---
+
+So, those are the highlights for the team and the community after a busy month. There are a lot more changes and improvements. I encourage you to read some release notes, starting from version [v1.57.0](https://github.com/immich-app/immich/releases/tag/v1.57.0) to now.
+
+Thank you, and I am asking for your support for the project. I hope to be a full-time maintainer of Immich one day to dedicate myself to the project as my life works for the community and my family. You can find the support channels below:
+
+- Monthly donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502)
+- One-time donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502)
+- [Liberapay](https://liberapay.com/alex.tran1502/)
+- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
+- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
+- Give a project a star - the contributors love gazing at the stars and seeing their creations shining in the sky.
+
+Join our friendly [Discord](https://discord.gg/D8JsnBEuKb) to talk and discuss Immich, tech, or anything
+
+Cheer!
+
+Until next time!
+
+Alex

+ 1 - 0
docs/docs/features/bulk-upload.md

@@ -42,6 +42,7 @@ immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recur
 | --server / -s    | Immich's server address                                             |
 | --threads / -t   | Number of threads to use (Default 5)                                |
 | --album/ -al     | Create albums for assets based on the parent folder or a given name |
+| --import/ -i     | Import gallery                                                      |
 
 ### Obtain the API Key
 

BIN
docs/docs/features/img/me.png


BIN
docs/docs/features/img/my-wife.png


+ 103 - 0
docs/docs/features/read-only-gallery.md

@@ -0,0 +1,103 @@
+# Read-only Gallery [Experimental]
+
+## Overview
+
+This feature enables users to use an existing gallery without uploading the assets to Immich.
+
+Upon syncing the file information, it will be read by Immich to generate supported files.
+
+:::caution
+
+This feature is still in an experimental stage. And this is an initial implementation and will receive improvements in the future.
+
+The current limitations of this feature are:
+
+- Assets are not automatically synced and must instead be manually synced with the CLI tool.
+- Only new files that are added to the gallery will be detected.
+- Deleted and moved files will not be detected.
+
+:::
+
+## Usage
+
+:::tip Example scenario
+
+On the VM/system that Immich is running, I have 2 galleries that I want to use with Immich.
+
+- My gallery is stored at `/mnt/media/precious-memory`
+- My wife's gallery is stored at `/mnt/media/childhood-memory`
+
+We will use those values in the steps below.
+
+:::
+
+### Mount the gallery to the containers.
+
+`immich-server` and `immich-microservices` containers will need access to the gallery. Mount the directory path as in the example below
+
+```diff title="docker-compose.yml"
+  immich-server:
+    container_name: immich_server
+    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
+    command: [ "start.sh", "immich" ]
+    volumes:
+      - ${UPLOAD_LOCATION}:/usr/src/app/upload
++     - /mnt/media/precious-memory:/mnt/media/precious-memory
++     - /mnt/media/childhood-memory:/mnt/media/childhood-memory
+    env_file:
+      - .env
+    depends_on:
+      - redis
+      - database
+      - typesense
+    restart: always
+
+  immich-microservices:
+    container_name: immich_microservices
+    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
+    command: [ "start.sh", "microservices" ]
+    volumes:
+      - ${UPLOAD_LOCATION}:/usr/src/app/upload
++     - /mnt/media/precious-memory:/mnt/media/precious-memory
++     - /mnt/media/childhood-memory:/mnt/media/childhood-memory
+    env_file:
+      - .env
+    depends_on:
+      - redis
+      - database
+      - typesense
+    restart: always
+```
+
+:::tip
+Internal and external path have to be identical.
+:::
+
+_Remember to bring the container down/up to register the changes. Make sure you can see the mounted path in the container._
+
+### Register the path for the user.
+
+This action is done by the admin of the instance.
+
+- Navigate to `Administration > Users` page on the web.
+- Click on the user edit button.
+- Add the gallery path to the `External Path` field for the corresponding user and confirm the changes.
+
+<img src={require('./img/me.png').default} width='33%' title='My Account Storage Path' />
+
+<img src={require('./img/my-wife.png').default} width='33%' title='My Wifes Account Storage Path' />
+
+### Sync with the CLI tool.
+
+- Install or update the [CLI Tool](/docs/features/bulk-upload.md). The import feature is supported from version `v0.39.0` of the CLI
+- Run the command below to sync the gallery with Immich.
+
+```bash title="Import my gallery"
+immich upload --key <my-api-key> --server http://my-server-ip:2283/api /mnt/media/precious-memory --recursive --import
+```
+
+```bash title="Import my wife gallery"
+immich upload --key <my-wife-api-key> --server http://my-server-ip:2283/api /mnt/media/childhood-memory --recursive --import
+```
+
+The `--import` flag will tell Immich to import the files by path instead of uploading them.

+ 5 - 0
docs/docusaurus.config.js

@@ -105,6 +105,11 @@ const config = {
             position: 'right',
             label: 'API',
           },
+          {
+            to: '/blog',
+            position: 'right',
+            label: 'Blog',
+          },
           {
             href: 'https://github.com/immich-app/immich',
             label: 'GitHub',

+ 2 - 2
machine-learning/Dockerfile

@@ -21,8 +21,8 @@ ENV NODE_ENV=production \
   PYTHONDONTWRITEBYTECODE=1 \
   PYTHONUNBUFFERED=1 \
   PATH="/opt/venv/bin:$PATH" \
-  PYTHONPATH=`pwd`
+  PYTHONPATH=/usr/src
 
 COPY --from=builder /opt/venv /opt/venv
 COPY app .
-ENTRYPOINT ["python", "main.py"]
+ENTRYPOINT ["python", "-m", "app.main"]

+ 9 - 0
machine-learning/README.md

@@ -11,3 +11,12 @@ Running `poetry install --no-root --with dev` will install everything you need i
 
 To add or remove dependencies, you can use the commands `poetry add $PACKAGE_NAME` and `poetry remove $PACKAGE_NAME`, respectively.
 Be sure to commit the `poetry.lock` and `pyproject.toml` files to reflect any changes in dependencies.
+
+
+# Load Testing
+
+To measure inference throughput and latency, you can use [Locust](https://locust.io/) using the provided `locustfile.py`.
+Locust works by querying the model endpoints and aggregating their statistics, meaning the app must be deployed.
+You can run `load_test.sh` to automatically deploy the app locally and start Locust, optionally adjusting its env variables as needed.
+
+Alternatively, for more custom testing, you may also run `locust` directly: see the [documentation](https://docs.locust.io/en/stable/index.html). Note that in Locust's jargon, concurrency is measured in `users`, and each user runs one task at a time. To achieve a particular per-endpoint concurrency, multiply that number by the number of endpoints to be queried. For example, if there are 3 endpoints and you want each of them to receive 8 requests at a time, you should set the number of users to 24.

+ 0 - 0
machine-learning/app/__init__.py


+ 10 - 1
machine-learning/app/config.py

@@ -1,5 +1,10 @@
+from pathlib import Path
+
 from pydantic import BaseSettings
 
+from .schemas import ModelType
+
+
 class Settings(BaseSettings):
     cache_folder: str = "/cache"
     classification_model: str = "microsoft/resnet-50"
@@ -15,8 +20,12 @@ class Settings(BaseSettings):
     min_face_score: float = 0.7
 
     class Config(BaseSettings.Config):
-        env_prefix = 'MACHINE_LEARNING_'
+        env_prefix = "MACHINE_LEARNING_"
         case_sensitive = False
 
 
+def get_cache_dir(model_name: str, model_type: ModelType) -> Path:
+    return Path(settings.cache_folder, model_type.value, model_name)
+
+
 settings = Settings()

+ 51 - 51
machine-learning/app/main.py

@@ -1,52 +1,58 @@
 import os
-import io
+from io import BytesIO
 from typing import Any
 
-from cache import ModelCache
-from schemas import (
+import cv2
+import numpy as np
+import uvicorn
+from fastapi import Body, Depends, FastAPI
+from PIL import Image
+
+from .config import settings
+from .models.base import InferenceModel
+from .models.cache import ModelCache
+from .schemas import (
     EmbeddingResponse,
     FaceResponse,
-    TagResponse,
     MessageResponse,
+    ModelType,
+    TagResponse,
     TextModelRequest,
     TextResponse,
 )
-import uvicorn
-from PIL import Image
-from fastapi import FastAPI, HTTPException, Depends, Body
-from models import get_model, run_classification, run_facial_recognition
-from config import settings
-
-_model_cache = None
 
 app = FastAPI()
 
 
 @app.on_event("startup")
 async def startup_event() -> None:
-    global _model_cache
-    _model_cache = ModelCache(ttl=settings.model_ttl, revalidate=True)
+    app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=True)
+    same_clip = settings.clip_image_model == settings.clip_text_model
+    app.state.clip_vision_type = ModelType.CLIP if same_clip else ModelType.CLIP_VISION
+    app.state.clip_text_type = ModelType.CLIP if same_clip else ModelType.CLIP_TEXT
     models = [
-        (settings.classification_model, "image-classification"),
-        (settings.clip_image_model, "clip"),
-        (settings.clip_text_model, "clip"),
-        (settings.facial_recognition_model, "facial-recognition"),
+        (settings.classification_model, ModelType.IMAGE_CLASSIFICATION),
+        (settings.clip_image_model, app.state.clip_vision_type),
+        (settings.clip_text_model, app.state.clip_text_type),
+        (settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION),
     ]
 
     # Get all models
     for model_name, model_type in models:
         if settings.eager_startup:
-            await _model_cache.get_cached_model(model_name, model_type)
+            await app.state.model_cache.get(model_name, model_type)
         else:
-            get_model(model_name, model_type)
+            InferenceModel.from_model_type(model_type, model_name)
+
 
+def dep_pil_image(byte_image: bytes = Body(...)) -> Image.Image:
+    return Image.open(BytesIO(byte_image))
 
-def dep_model_cache():
-    if _model_cache is None:
-        raise HTTPException(status_code=500, detail="Unable to load model.")
 
-def dep_input_image(image: bytes = Body(...)) -> Image:
-    return Image.open(io.BytesIO(image))
+def dep_cv_image(byte_image: bytes = Body(...)) -> cv2.Mat:
+    byte_image_np = np.frombuffer(byte_image, np.uint8)
+    return cv2.imdecode(byte_image_np, cv2.IMREAD_COLOR)
+
 
 @app.get("/", response_model=MessageResponse)
 async def root() -> dict[str, str]:
@@ -62,33 +68,29 @@ def ping() -> str:
     "/image-classifier/tag-image",
     response_model=TagResponse,
     status_code=200,
-    dependencies=[Depends(dep_model_cache)],
 )
 async def image_classification(
-    image: Image = Depends(dep_input_image)
+    image: Image.Image = Depends(dep_pil_image),
 ) -> list[str]:
-    try:
-        model = await _model_cache.get_cached_model(
-            settings.classification_model, "image-classification"
-        )
-        labels = run_classification(model, image, settings.min_tag_score)
-    except Exception as ex:
-        raise HTTPException(status_code=500, detail=str(ex))
-    else:
-        return labels
+    model = await app.state.model_cache.get(
+        settings.classification_model, ModelType.IMAGE_CLASSIFICATION
+    )
+    labels = model.predict(image)
+    return labels
 
 
 @app.post(
     "/sentence-transformer/encode-image",
     response_model=EmbeddingResponse,
     status_code=200,
-    dependencies=[Depends(dep_model_cache)],
 )
 async def clip_encode_image(
-    image: Image = Depends(dep_input_image)
+    image: Image.Image = Depends(dep_pil_image),
 ) -> list[float]:
-    model = await _model_cache.get_cached_model(settings.clip_image_model, "clip")
-    embedding = model.encode(image).tolist()
+    model = await app.state.model_cache.get(
+        settings.clip_image_model, app.state.clip_vision_type
+    )
+    embedding = model.predict(image)
     return embedding
 
 
@@ -96,13 +98,12 @@ async def clip_encode_image(
     "/sentence-transformer/encode-text",
     response_model=EmbeddingResponse,
     status_code=200,
-    dependencies=[Depends(dep_model_cache)],
 )
-async def clip_encode_text(
-    payload: TextModelRequest
-) -> list[float]:
-    model = await _model_cache.get_cached_model(settings.clip_text_model, "clip")
-    embedding = model.encode(payload.text).tolist()
+async def clip_encode_text(payload: TextModelRequest) -> list[float]:
+    model = await app.state.model_cache.get(
+        settings.clip_text_model, app.state.clip_text_type
+    )
+    embedding = model.predict(payload.text)
     return embedding
 
 
@@ -110,22 +111,21 @@ async def clip_encode_text(
     "/facial-recognition/detect-faces",
     response_model=FaceResponse,
     status_code=200,
-    dependencies=[Depends(dep_model_cache)],
 )
 async def facial_recognition(
-    image: bytes = Body(...),
+    image: cv2.Mat = Depends(dep_cv_image),
 ) -> list[dict[str, Any]]:
-    model = await _model_cache.get_cached_model(
-        settings.facial_recognition_model, "facial-recognition"
+    model = await app.state.model_cache.get(
+        settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION
     )
-    faces = run_facial_recognition(model, image)
+    faces = model.predict(image)
     return faces
 
 
 if __name__ == "__main__":
     is_dev = os.getenv("NODE_ENV") == "development"
     uvicorn.run(
-        "main:app",
+        "app.main:app",
         host=settings.host,
         port=settings.port,
         reload=is_dev,

+ 0 - 119
machine-learning/app/models.py

@@ -1,119 +0,0 @@
-import torch
-from insightface.app import FaceAnalysis
-from pathlib import Path
-
-from transformers import pipeline, Pipeline
-from sentence_transformers import SentenceTransformer
-from typing import Any, BinaryIO
-import cv2 as cv
-import numpy as np
-from PIL import Image
-from config import settings
-
-device = "cuda" if torch.cuda.is_available() else "cpu"
-
-
-def get_model(model_name: str, model_type: str, **model_kwargs):
-    """
-    Instantiates the specified model.
-
-    Args:
-        model_name: Name of model in the model hub used for the task.
-        model_type: Model type or task, which determines which model zoo is used.
-            `facial-recognition` uses Insightface, while all other models use the HF Model Hub.
-
-            Options:
-                `image-classification`, `clip`,`facial-recognition`, `tokenizer`, `processor`
-
-    Returns:
-        model: The requested model.
-    """
-
-    cache_dir = _get_cache_dir(model_name, model_type)
-    match model_type:
-        case "facial-recognition":
-            model = _load_facial_recognition(
-                model_name, cache_dir=cache_dir, **model_kwargs
-            )
-        case "clip":
-            model = SentenceTransformer(
-                model_name, cache_folder=cache_dir, **model_kwargs
-            )
-        case _:
-            model = pipeline(
-                model_type,
-                model_name,
-                model_kwargs={"cache_dir": cache_dir, **model_kwargs},
-            )
-
-    return model
-
-
-def run_classification(
-    model: Pipeline, image: Image, min_score: float | None = None
-):
-    predictions: list[dict[str, Any]] = model(image)  # type: ignore
-    result = {
-        tag
-        for pred in predictions
-        for tag in pred["label"].split(", ")
-        if min_score is None or pred["score"] >= min_score
-    }
-
-    return list(result)
-
-
-def run_facial_recognition(
-    model: FaceAnalysis, image: bytes
-) -> list[dict[str, Any]]:
-    file_bytes = np.frombuffer(image, dtype=np.uint8)
-    img = cv.imdecode(file_bytes, cv.IMREAD_COLOR)
-    height, width, _ = img.shape
-    results = []
-    faces = model.get(img)
-
-    for face in faces:
-        x1, y1, x2, y2 = face.bbox
-
-        results.append(
-            {
-                "imageWidth": width,
-                "imageHeight": height,
-                "boundingBox": {
-                    "x1": round(x1),
-                    "y1": round(y1),
-                    "x2": round(x2),
-                    "y2": round(y2),
-                },
-                "score": face.det_score.item(),
-                "embedding": face.normed_embedding.tolist(),
-            }
-        )
-    return results
-
-
-def _load_facial_recognition(
-    model_name: str,
-    min_face_score: float | None = None,
-    cache_dir: Path | str | None = None,
-    **model_kwargs,
-):
-    if cache_dir is None:
-        cache_dir = _get_cache_dir(model_name, "facial-recognition")
-    if isinstance(cache_dir, Path):
-        cache_dir = cache_dir.as_posix()
-    if min_face_score is None:
-        min_face_score = settings.min_face_score
-
-    model = FaceAnalysis(
-        name=model_name,
-        root=cache_dir,
-        allowed_modules=["detection", "recognition"],
-        **model_kwargs,
-    )
-    model.prepare(ctx_id=0, det_thresh=min_face_score, det_size=(640, 640))
-    return model
-
-
-def _get_cache_dir(model_name: str, model_type: str) -> Path:
-    return Path(settings.cache_folder, device, model_type, model_name)

+ 3 - 0
machine-learning/app/models/__init__.py

@@ -0,0 +1,3 @@
+from .clip import CLIPSTTextEncoder, CLIPSTVisionEncoder
+from .facial_recognition import FaceRecognizer
+from .image_classification import ImageClassifier

+ 52 - 0
machine-learning/app/models/base.py

@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from abc import abstractmethod, ABC
+from pathlib import Path
+from typing import Any
+
+from ..config import get_cache_dir
+from ..schemas import ModelType
+
+
+class InferenceModel(ABC):
+    _model_type: ModelType
+
+    def __init__(
+        self,
+        model_name: str,
+        cache_dir: Path | None = None,
+    ):
+        self.model_name = model_name
+        self._cache_dir = (
+            cache_dir
+            if cache_dir is not None
+            else get_cache_dir(model_name, self.model_type)
+        )
+
+    @abstractmethod
+    def predict(self, inputs: Any) -> Any:
+        ...
+
+    @property
+    def model_type(self) -> ModelType:
+        return self._model_type
+
+    @property
+    def cache_dir(self) -> Path:
+        return self._cache_dir
+
+    @cache_dir.setter
+    def cache_dir(self, cache_dir: Path):
+        self._cache_dir = cache_dir
+
+    @classmethod
+    def from_model_type(
+        cls, model_type: ModelType, model_name, **model_kwargs
+    ) -> InferenceModel:
+        subclasses = {
+            subclass._model_type: subclass for subclass in cls.__subclasses__()
+        }
+        if model_type not in subclasses:
+            raise ValueError(f"Unsupported model type: {model_type}")
+
+        return subclasses[model_type](model_name, **model_kwargs)

+ 17 - 9
machine-learning/app/cache.py → machine-learning/app/models/cache.py

@@ -1,8 +1,11 @@
-from aiocache.plugins import TimingPlugin, BasePlugin
+import asyncio
+
 from aiocache.backends.memory import SimpleMemoryCache
 from aiocache.lock import OptimisticLock
-from typing import Any
-from models import get_model
+from aiocache.plugins import BasePlugin, TimingPlugin
+
+from ..schemas import ModelType
+from .base import InferenceModel
 
 
 class ModelCache:
@@ -10,7 +13,7 @@ class ModelCache:
 
     def __init__(
         self,
-        ttl: int | None = None,
+        ttl: float | None = None,
         revalidate: bool = False,
         timeout: int | None = None,
         profiling: bool = False,
@@ -35,9 +38,9 @@ class ModelCache:
             ttl=ttl, timeout=timeout, plugins=plugins, namespace=None
         )
 
-    async def get_cached_model(
-        self, model_name: str, model_type: str, **model_kwargs
-    ) -> Any:
+    async def get(
+        self, model_name: str, model_type: ModelType, **model_kwargs
+    ) -> InferenceModel:
         """
         Args:
             model_name: Name of model in the model hub used for the task.
@@ -47,11 +50,16 @@ class ModelCache:
             model: The requested model.
         """
 
-        key = self.cache.build_key(model_name, model_type)
+        key = self.cache.build_key(model_name, model_type.value)
         model = await self.cache.get(key)
         if model is None:
             async with OptimisticLock(self.cache, key) as lock:
-                model = get_model(model_name, model_type, **model_kwargs)
+                model = await asyncio.get_running_loop().run_in_executor(
+                    None,
+                    lambda: InferenceModel.from_model_type(
+                        model_type, model_name, **model_kwargs
+                    ),
+                )
                 await lock.cas(model, ttl=self.ttl)
         return model
 

+ 37 - 0
machine-learning/app/models/clip.py

@@ -0,0 +1,37 @@
+from pathlib import Path
+
+from PIL.Image import Image
+from sentence_transformers import SentenceTransformer
+
+from ..schemas import ModelType
+from .base import InferenceModel
+
+
+class CLIPSTEncoder(InferenceModel):
+    _model_type = ModelType.CLIP
+
+    def __init__(
+        self,
+        model_name: str,
+        cache_dir: Path | None = None,
+        **model_kwargs,
+    ):
+        super().__init__(model_name, cache_dir)
+        self.model = SentenceTransformer(
+            self.model_name,
+            cache_folder=self.cache_dir.as_posix(),
+            **model_kwargs,
+        )
+
+    def predict(self, image_or_text: Image | str) -> list[float]:
+        return self.model.encode(image_or_text).tolist()
+
+
+# stubs to allow different behavior between the two in the future
+# and handle loading different image and text clip models
+class CLIPSTVisionEncoder(CLIPSTEncoder):
+    _model_type = ModelType.CLIP_VISION
+
+
+class CLIPSTTextEncoder(CLIPSTEncoder):
+    _model_type = ModelType.CLIP_TEXT

+ 59 - 0
machine-learning/app/models/facial_recognition.py

@@ -0,0 +1,59 @@
+from pathlib import Path
+from typing import Any
+
+import cv2
+from insightface.app import FaceAnalysis
+
+from ..config import settings
+from ..schemas import ModelType
+from .base import InferenceModel
+
+
+class FaceRecognizer(InferenceModel):
+    _model_type = ModelType.FACIAL_RECOGNITION
+
+    def __init__(
+        self,
+        model_name: str,
+        min_score: float = settings.min_face_score,
+        cache_dir: Path | None = None,
+        **model_kwargs,
+    ):
+        super().__init__(model_name, cache_dir)
+        self.min_score = min_score
+        model = FaceAnalysis(
+            name=self.model_name,
+            root=self.cache_dir.as_posix(),
+            allowed_modules=["detection", "recognition"],
+            **model_kwargs,
+        )
+        model.prepare(
+            ctx_id=0,
+            det_thresh=self.min_score,
+            det_size=(640, 640),
+        )
+        self.model = model
+
+    def predict(self, image: cv2.Mat) -> list[dict[str, Any]]:
+        height, width, _ = image.shape
+        results = []
+        faces = self.model.get(image)
+
+        for face in faces:
+            x1, y1, x2, y2 = face.bbox
+
+            results.append(
+                {
+                    "imageWidth": width,
+                    "imageHeight": height,
+                    "boundingBox": {
+                        "x1": round(x1),
+                        "y1": round(y1),
+                        "x2": round(x2),
+                        "y2": round(y2),
+                    },
+                    "score": face.det_score.item(),
+                    "embedding": face.normed_embedding.tolist(),
+                }
+            )
+        return results

+ 40 - 0
machine-learning/app/models/image_classification.py

@@ -0,0 +1,40 @@
+from pathlib import Path
+
+from PIL.Image import Image
+from transformers.pipelines import pipeline
+
+from ..config import settings
+from ..schemas import ModelType
+from .base import InferenceModel
+
+
+class ImageClassifier(InferenceModel):
+    _model_type = ModelType.IMAGE_CLASSIFICATION
+
+    def __init__(
+        self,
+        model_name: str,
+        min_score: float = settings.min_tag_score,
+        cache_dir: Path | None = None,
+        **model_kwargs,
+    ):
+        super().__init__(model_name, cache_dir)
+        self.min_score = min_score
+
+        self.model = pipeline(
+            self.model_type.value,
+            self.model_name,
+            model_kwargs={"cache_dir": self.cache_dir, **model_kwargs},
+        )
+
+    def predict(self, image: Image) -> list[str]:
+        predictions = self.model(image)
+        tags = list(
+            {
+                tag
+                for pred in predictions
+                for tag in pred["label"].split(", ")
+                if pred["score"] >= self.min_score
+            }
+        )
+        return tags

+ 10 - 0
machine-learning/app/schemas.py

@@ -1,3 +1,5 @@
+from enum import Enum
+
 from pydantic import BaseModel
 
 
@@ -54,3 +56,11 @@ class Face(BaseModel):
 
 class FaceResponse(BaseModel):
     __root__: list[Face]
+
+
+class ModelType(Enum):
+    IMAGE_CLASSIFICATION = "image-classification"
+    CLIP = "clip"
+    CLIP_VISION = "clip-vision"
+    CLIP_TEXT = "clip-text"
+    FACIAL_RECOGNITION = "facial-recognition"

+ 24 - 0
machine-learning/load_test.sh

@@ -0,0 +1,24 @@
+export MACHINE_LEARNING_CACHE_FOLDER=/tmp/model_cache
+export MACHINE_LEARNING_MIN_FACE_SCORE=0.034 # returns 1 face per request; setting this to 0 blows up the number of faces to the thousands
+export MACHINE_LEARNING_MIN_TAG_SCORE=0.0
+export PID_FILE=/tmp/locust_pid
+export LOG_FILE=/tmp/gunicorn.log
+export HEADLESS=false
+export HOST=127.0.0.1:3003
+export CONCURRENCY=4
+export NUM_ENDPOINTS=3
+export PYTHONPATH=app
+
+gunicorn app.main:app --worker-class uvicorn.workers.UvicornWorker \
+    --bind $HOST --daemon --error-logfile $LOG_FILE --pid $PID_FILE
+while true ; do
+    echo "Loading models..."
+    sleep 5
+    if cat $LOG_FILE | grep -q -E "startup complete"; then break; fi
+done
+
+# "users" are assigned only one task, so multiply concurrency by the number of tasks
+locust --host http://$HOST --web-host 127.0.0.1 \
+    --run-time 120s --users $(($CONCURRENCY * $NUM_ENDPOINTS)) $(if $HEADLESS; then echo "--headless"; fi)
+
+if [[ -e $PID_FILE ]]; then kill $(cat $PID_FILE); fi

+ 52 - 0
machine-learning/locustfile.py

@@ -0,0 +1,52 @@
+from io import BytesIO
+
+from locust import HttpUser, events, task
+from PIL import Image
+
+
+@events.test_start.add_listener
+def on_test_start(environment, **kwargs):
+    global byte_image
+    image = Image.new("RGB", (1000, 1000))
+    byte_image = BytesIO()
+    image.save(byte_image, format="jpeg")
+
+
+class InferenceLoadTest(HttpUser):
+    abstract: bool = True
+    host = "http://127.0.0.1:3003"
+    data: bytes
+    headers: dict[str, str] = {"Content-Type": "image/jpg"}
+
+    # re-use the image across all instances in a process
+    def on_start(self):
+        global byte_image
+        self.data = byte_image.getvalue()
+
+
+class ClassificationLoadTest(InferenceLoadTest):
+    @task
+    def classify(self):
+        self.client.post(
+            "/image-classifier/tag-image", data=self.data, headers=self.headers
+        )
+
+
+class CLIPLoadTest(InferenceLoadTest):
+    @task
+    def encode_image(self):
+        self.client.post(
+            "/sentence-transformer/encode-image",
+            data=self.data,
+            headers=self.headers,
+        )
+
+
+class RecognitionLoadTest(InferenceLoadTest):
+    @task
+    def recognize(self):
+        self.client.post(
+            "/facial-recognition/detect-faces",
+            data=self.data,
+            headers=self.headers,
+        )

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


+ 3 - 1
machine-learning/pyproject.toml

@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "machine-learning"
-version = "1.59.1"
+version = "1.64.0"
 description = ""
 authors = ["Hau Tran <alex.tran1502@gmail.com>"]
 readme = "README.md"
@@ -27,6 +27,8 @@ aiocache = "^0.12.1"
 mypy = "^1.3.0"
 black = "^23.3.0"
 pytest = "^7.3.1"
+locust = "^2.15.1"
+gunicorn = "^20.1.0"
 
 [[tool.poetry.source]]
 name = "pytorch-cpu"

+ 2 - 2
mobile/android/fastlane/Fastfile

@@ -35,8 +35,8 @@ platform :android do
       task: 'bundle', 
       build_type: 'Release',
       properties: {
-        "android.injected.version.code" => 85,
-        "android.injected.version.name" => "1.62.1",
+        "android.injected.version.code" => 87,
+        "android.injected.version.name" => "1.64.0",
       }
     )
     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')

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

@@ -217,6 +217,7 @@
   "search_page_selfies": "Selfies",
   "search_page_things": "Things",
   "search_page_videos": "Videos",
+  "search_page_people": "People",
   "search_page_view_all_button": "View all",
   "search_page_your_activity": "Your activity",
   "search_result_page_new_search_hint": "New Search",
@@ -285,5 +286,6 @@
   "version_announcement_overlay_text_1": "Hi friend, there is a new release of",
   "version_announcement_overlay_text_2": "please take your time to visit the ",
   "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
-  "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
-}
+  "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
+  "all_people_page_title": "People"
+}

+ 6 - 6
mobile/ios/Podfile.lock

@@ -39,7 +39,7 @@ PODS:
   - shared_preferences_foundation (0.0.1):
     - Flutter
     - FlutterMacOS
-  - sqflite (0.0.2):
+  - sqflite (0.0.3):
     - Flutter
     - FMDB (>= 2.7.5)
   - Toast (4.0.0)
@@ -128,21 +128,21 @@ SPEC CHECKSUMS:
   flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
   fluttertoast: eb263d302cc92e04176c053d2385237e9f43fad0
   FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
-  image_picker_ios: 58b9c4269cb176f89acea5e5d043c9358f2d25f8
+  image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
   integration_test: 13825b8a9334a850581300559b8839134b124670
   isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
   package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
-  path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9
+  path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8
   path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
   permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce
   photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
   share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
-  shared_preferences_foundation: 986fc17f3d3251412d18b0265f9c64113a8c2472
-  sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
+  shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
+  sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
   Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
   url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
-  video_player_avfoundation: 6d971a232d72e6ee25368378d48a079dea01f1cf
+  video_player_avfoundation: 81e49bb3d9fb63dccf9fa0f6d877dc3ddbeac126
   wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
 
 PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382

+ 1 - 1
mobile/ios/fastlane/Fastfile

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

+ 21 - 0
mobile/lib/modules/asset_viewer/providers/show_controls.provider.dart

@@ -0,0 +1,21 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+final showControlsProvider = StateNotifierProvider<ShowControls, bool>((ref) {
+  return ShowControls(ref);
+});
+
+class ShowControls extends StateNotifier<bool> {
+  ShowControls(this.ref) : super(true);
+
+  final Ref ref;
+
+  bool get show => state;
+
+  set show(bool value) {
+    state = value;
+  }
+
+  void toggle() {
+    state = !state;
+  }
+}

+ 46 - 0
mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart

@@ -0,0 +1,46 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+class VideoPlaybackControls {
+  VideoPlaybackControls({required this.position, required this.mute});
+
+  final double position;
+  final bool mute;
+}
+
+final videoPlayerControlsProvider =
+    StateNotifierProvider<VideoPlayerControls, VideoPlaybackControls>((ref) {
+  return VideoPlayerControls(ref);
+});
+
+class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
+  VideoPlayerControls(this.ref)
+      : super(
+          VideoPlaybackControls(
+            position: 0,
+            mute: false,
+          ),
+        );
+
+  final Ref ref;
+
+  VideoPlaybackControls get value => state;
+
+  set value(VideoPlaybackControls value) {
+    state = value;
+  }
+
+  double get position => state.position;
+  bool get mute => state.mute;
+
+  set position(double value) {
+    state = VideoPlaybackControls(position: value, mute: state.mute);
+  }
+
+  set mute(bool value) {
+    state = VideoPlaybackControls(position: state.position, mute: value);
+  }
+
+  void toggleMute() {
+    state = VideoPlaybackControls(position: state.position, mute: !state.mute);
+  }
+}

+ 35 - 0
mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart

@@ -0,0 +1,35 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+class VideoPlaybackValue {
+  VideoPlaybackValue({required this.position, required this.duration});
+
+  final Duration position;
+  final Duration duration;
+}
+
+final videoPlaybackValueProvider =
+    StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
+  return VideoPlaybackValueState(ref);
+});
+
+class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
+  VideoPlaybackValueState(this.ref)
+      : super(
+          VideoPlaybackValue(
+            position: Duration.zero,
+            duration: Duration.zero,
+          ),
+        );
+
+  final Ref ref;
+
+  VideoPlaybackValue get value => state;
+
+  set value(VideoPlaybackValue value) {
+    state = value;
+  }
+
+  set position(Duration value) {
+    state = VideoPlaybackValue(position: value, duration: state.duration);
+  }
+}

+ 57 - 0
mobile/lib/modules/asset_viewer/ui/animated_play_pause.dart

@@ -0,0 +1,57 @@
+import 'package:flutter/material.dart';
+
+/// A widget that animates implicitly between a play and a pause icon.
+class AnimatedPlayPause extends StatefulWidget {
+  const AnimatedPlayPause({
+    Key? key,
+    required this.playing,
+    this.size,
+    this.color,
+  }) : super(key: key);
+
+  final double? size;
+  final bool playing;
+  final Color? color;
+
+  @override
+  State<StatefulWidget> createState() => AnimatedPlayPauseState();
+}
+
+class AnimatedPlayPauseState extends State<AnimatedPlayPause>
+    with SingleTickerProviderStateMixin {
+  late final animationController = AnimationController(
+    vsync: this,
+    value: widget.playing ? 1 : 0,
+    duration: const Duration(milliseconds: 300),
+  );
+
+  @override
+  void didUpdateWidget(AnimatedPlayPause oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.playing != oldWidget.playing) {
+      if (widget.playing) {
+        animationController.forward();
+      } else {
+        animationController.reverse();
+      }
+    }
+  }
+
+  @override
+  void dispose() {
+    animationController.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Center(
+      child: AnimatedIcon(
+        color: widget.color,
+        size: widget.size,
+        icon: AnimatedIcons.play_pause,
+        progress: animationController,
+      ),
+    );
+  }
+}

+ 53 - 0
mobile/lib/modules/asset_viewer/ui/center_play_button.dart

@@ -0,0 +1,53 @@
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/modules/asset_viewer/ui/animated_play_pause.dart';
+
+class CenterPlayButton extends StatelessWidget {
+  const CenterPlayButton({
+    Key? key,
+    required this.backgroundColor,
+    this.iconColor,
+    required this.show,
+    required this.isPlaying,
+    required this.isFinished,
+    this.onPressed,
+  }) : super(key: key);
+
+  final Color backgroundColor;
+  final Color? iconColor;
+  final bool show;
+  final bool isPlaying;
+  final bool isFinished;
+  final VoidCallback? onPressed;
+
+  @override
+  Widget build(BuildContext context) {
+    return ColoredBox(
+      color: Colors.transparent,
+      child: Center(
+        child: UnconstrainedBox(
+          child: AnimatedOpacity(
+            opacity: show ? 1.0 : 0.0,
+            duration: const Duration(milliseconds: 100),
+            child: DecoratedBox(
+              decoration: BoxDecoration(
+                color: backgroundColor,
+                shape: BoxShape.circle,
+              ),
+              child: IconButton(
+                iconSize: 32,
+                padding: const EdgeInsets.all(12.0),
+                icon: isFinished
+                    ? Icon(Icons.replay, color: iconColor)
+                    : AnimatedPlayPause(
+                        color: iconColor,
+                        playing: isPlaying,
+                      ),
+                onPressed: onPressed,
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 207 - 0
mobile/lib/modules/asset_viewer/ui/video_player_controls.dart

@@ -0,0 +1,207 @@
+import 'dart:async';
+
+import 'package:chewie/chewie.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
+import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
+import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
+import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:video_player/video_player.dart';
+
+class VideoPlayerControls extends ConsumerStatefulWidget {
+  const VideoPlayerControls({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  VideoPlayerControlsState createState() => VideoPlayerControlsState();
+}
+
+class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
+    with SingleTickerProviderStateMixin {
+  late VideoPlayerController controller;
+  late VideoPlayerValue _latestValue;
+  bool _displayBufferingIndicator = false;
+  double? _latestVolume;
+  Timer? _hideTimer;
+
+  ChewieController? _chewieController;
+  ChewieController get chewieController => _chewieController!;
+
+  @override
+  Widget build(BuildContext context) {
+    ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
+        (_, value) {
+      _mute(value);
+      _cancelAndRestartTimer();
+    });
+
+    ref.listen(videoPlayerControlsProvider.select((value) => value.position),
+        (_, position) {
+      _seekTo(position);
+      _cancelAndRestartTimer();
+    });
+
+    if (_latestValue.hasError) {
+      return chewieController.errorBuilder?.call(
+            context,
+            chewieController.videoPlayerController.value.errorDescription!,
+          ) ??
+          const Center(
+            child: Icon(
+              Icons.error,
+              color: Colors.white,
+              size: 42,
+            ),
+          );
+    }
+
+    return GestureDetector(
+      onTap: () => _cancelAndRestartTimer(),
+      child: AbsorbPointer(
+        absorbing: !ref.watch(showControlsProvider),
+        child: Stack(
+          children: [
+            if (_displayBufferingIndicator)
+              const Center(
+                child: ImmichLoadingIndicator(),
+              )
+            else
+              _buildHitArea(),
+          ],
+        ),
+      ),
+    );
+  }
+
+  @override
+  void dispose() {
+    _dispose();
+    super.dispose();
+  }
+
+  void _dispose() {
+    controller.removeListener(_updateState);
+    _hideTimer?.cancel();
+  }
+
+  @override
+  void didChangeDependencies() {
+    final oldController = _chewieController;
+    _chewieController = ChewieController.of(context);
+    controller = chewieController.videoPlayerController;
+
+    if (oldController != chewieController) {
+      _dispose();
+      _initialize();
+    }
+
+    super.didChangeDependencies();
+  }
+
+  Widget _buildHitArea() {
+    final bool isFinished = _latestValue.position >= _latestValue.duration;
+
+    return GestureDetector(
+      onTap: () {
+        if (_latestValue.isPlaying) {
+          ref.read(showControlsProvider.notifier).show = false;
+        } else {
+          _playPause();
+          ref.read(showControlsProvider.notifier).show = false;
+        }
+      },
+      child: CenterPlayButton(
+        backgroundColor: Colors.black54,
+        iconColor: Colors.white,
+        isFinished: isFinished,
+        isPlaying: controller.value.isPlaying,
+        show: ref.watch(showControlsProvider),
+        onPressed: _playPause,
+      ),
+    );
+  }
+
+  void _cancelAndRestartTimer() {
+    _hideTimer?.cancel();
+    _startHideTimer();
+    ref.read(showControlsProvider.notifier).show = true;
+  }
+
+  Future<void> _initialize() async {
+    _mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute)));
+
+    controller.addListener(_updateState);
+    _latestValue = controller.value;
+
+    if (controller.value.isPlaying || chewieController.autoPlay) {
+      _startHideTimer();
+    }
+  }
+
+  void _playPause() {
+    final isFinished = _latestValue.position >= _latestValue.duration;
+
+    setState(() {
+      if (controller.value.isPlaying) {
+        ref.read(showControlsProvider.notifier).show = true;
+        _hideTimer?.cancel();
+        controller.pause();
+      } else {
+        _cancelAndRestartTimer();
+
+        if (!controller.value.isInitialized) {
+          controller.initialize().then((_) {
+            controller.play();
+          });
+        } else {
+          if (isFinished) {
+            controller.seekTo(Duration.zero);
+          }
+          controller.play();
+        }
+      }
+    });
+  }
+
+  void _startHideTimer() {
+    final hideControlsTimer = chewieController.hideControlsTimer.isNegative
+        ? ChewieController.defaultHideControlsTimer
+        : chewieController.hideControlsTimer;
+    _hideTimer = Timer(hideControlsTimer, () {
+      ref.read(showControlsProvider.notifier).show = false;
+    });
+  }
+
+  void _updateState() {
+    if (!mounted) return;
+
+    _displayBufferingIndicator = controller.value.isBuffering;
+
+    setState(() {
+      _latestValue = controller.value;
+      ref.read(videoPlaybackValueProvider.notifier).value = VideoPlaybackValue(
+        position: _latestValue.position,
+        duration: _latestValue.duration,
+      );
+    });
+  }
+
+  void _mute(bool mute) {
+    if (mute) {
+      _latestVolume = controller.value.volume;
+      controller.setVolume(0);
+    } else {
+      controller.setVolume(_latestVolume ?? 0.5);
+    }
+  }
+
+  void _seekTo(double position) {
+    final Duration pos = controller.value.duration * (position / 100.0);
+    if (pos != controller.value.position) {
+      controller.seekTo(pos);
+    }
+  }
+}

+ 188 - 67
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -1,4 +1,5 @@
 import 'dart:io';
+import 'dart:math';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:auto_route/auto_route.dart';
 import 'package:cached_network_image/cached_network_image.dart';
@@ -6,8 +7,11 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
+import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
 import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
+import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
@@ -49,9 +53,9 @@ class GalleryViewerPage extends HookConsumerWidget {
     final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
     final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
     final isZoomed = useState<bool>(false);
-    final showAppBar = useState<bool>(true);
     final isPlayingMotionVideo = useState(false);
     final isPlayingVideo = useState(false);
+    final progressValue = useState(0.0);
     Offset? localPosition;
     final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
     final currentIndex = useState(initialIndex);
@@ -60,15 +64,6 @@ class GalleryViewerPage extends HookConsumerWidget {
 
     Asset asset() => watchedAsset.value ?? currentAsset;
 
-    showAppBar.addListener(() {
-      // Change to and from immersive mode, hiding navigation and app bar
-      if (showAppBar.value) {
-        SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
-      } else {
-        SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
-      }
-    });
-
     useEffect(
       () {
         isLoadPreview.value =
@@ -277,15 +272,11 @@ class GalleryViewerPage extends HookConsumerWidget {
     }
 
     buildAppBar() {
-      final show = (showAppBar.value || // onTap has the final say
-              (showAppBar.value && !isZoomed.value)) &&
-          !isPlayingVideo.value;
-
       return IgnorePointer(
-        ignoring: !show,
+        ignoring: !ref.watch(showControlsProvider),
         child: AnimatedOpacity(
           duration: const Duration(milliseconds: 100),
-          opacity: show ? 1.0 : 0.0,
+          opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
           child: Container(
             color: Colors.black.withOpacity(0.4),
             child: TopControlAppBar(
@@ -313,65 +304,160 @@ class GalleryViewerPage extends HookConsumerWidget {
       );
     }
 
-    buildBottomBar() {
-      final show = (showAppBar.value || // onTap has the final say
-              (showAppBar.value && !isZoomed.value)) &&
-          !isPlayingVideo.value;
+    Widget buildProgressBar() {
+      final playerValue = ref.watch(videoPlaybackValueProvider);
+
+      return Expanded(
+        child: Slider(
+          value: playerValue.duration == Duration.zero
+              ? 0.0
+              : min(
+                  playerValue.position.inMicroseconds /
+                      playerValue.duration.inMicroseconds *
+                      100,
+                  100,
+                ),
+          min: 0,
+          max: 100,
+          thumbColor: Colors.white,
+          activeColor: Colors.white,
+          inactiveColor: Colors.white.withOpacity(0.75),
+          onChanged: (position) {
+            progressValue.value = position;
+            ref.read(videoPlayerControlsProvider.notifier).position = position;
+          },
+        ),
+      );
+    }
+
+    Text buildPosition() {
+      final position = ref
+          .watch(videoPlaybackValueProvider.select((value) => value.position));
+
+      return Text(
+        _formatDuration(position),
+        style: TextStyle(
+          fontSize: 14.0,
+          color: Colors.white.withOpacity(.75),
+          fontWeight: FontWeight.normal,
+        ),
+      );
+    }
+
+    Text buildDuration() {
+      final duration = ref
+          .watch(videoPlaybackValueProvider.select((value) => value.duration));
+
+      return Text(
+        _formatDuration(duration),
+        style: TextStyle(
+          fontSize: 14.0,
+          color: Colors.white.withOpacity(.75),
+          fontWeight: FontWeight.normal,
+        ),
+      );
+    }
 
+    Widget buildMuteButton() {
+      return IconButton(
+        icon: Icon(
+          ref.watch(videoPlayerControlsProvider.select((value) => value.mute))
+              ? Icons.volume_off
+              : Icons.volume_up,
+        ),
+        onPressed: () =>
+            ref.read(videoPlayerControlsProvider.notifier).toggleMute(),
+        color: Colors.white,
+      );
+    }
+
+    buildBottomBar() {
       return IgnorePointer(
-        ignoring: !show,
+        ignoring: !ref.watch(showControlsProvider),
         child: AnimatedOpacity(
           duration: const Duration(milliseconds: 100),
-          opacity: show ? 1.0 : 0.0,
-          child: BottomNavigationBar(
-            backgroundColor: Colors.black.withOpacity(0.4),
-            unselectedIconTheme: const IconThemeData(color: Colors.white),
-            selectedIconTheme: const IconThemeData(color: Colors.white),
-            unselectedLabelStyle: const TextStyle(color: Colors.black),
-            selectedLabelStyle: const TextStyle(color: Colors.black),
-            showSelectedLabels: false,
-            showUnselectedLabels: false,
-            items: [
-              BottomNavigationBarItem(
-                icon: const Icon(Icons.ios_share_rounded),
-                label: 'control_bottom_app_bar_share'.tr(),
-                tooltip: 'control_bottom_app_bar_share'.tr(),
-              ),
-              asset().isArchived
-                  ? BottomNavigationBarItem(
-                      icon: const Icon(Icons.unarchive_rounded),
-                      label: 'control_bottom_app_bar_unarchive'.tr(),
-                      tooltip: 'control_bottom_app_bar_unarchive'.tr(),
-                    )
-                  : BottomNavigationBarItem(
-                      icon: const Icon(Icons.archive_outlined),
-                      label: 'control_bottom_app_bar_archive'.tr(),
-                      tooltip: 'control_bottom_app_bar_archive'.tr(),
+          opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
+          child: Column(
+            children: [
+              Visibility(
+                visible: !asset().isImage && !isPlayingMotionVideo.value,
+                child: Container(
+                  color: Colors.black.withOpacity(0.4),
+                  child: Padding(
+                    padding: MediaQuery.of(context).orientation ==
+                            Orientation.portrait
+                        ? const EdgeInsets.symmetric(horizontal: 12.0)
+                        : const EdgeInsets.symmetric(horizontal: 64.0),
+                    child: Row(
+                      children: [
+                        buildPosition(),
+                        buildProgressBar(),
+                        buildDuration(),
+                        buildMuteButton(),
+                      ],
                     ),
-              BottomNavigationBarItem(
-                icon: const Icon(Icons.delete_outline),
-                label: 'control_bottom_app_bar_delete'.tr(),
-                tooltip: 'control_bottom_app_bar_delete'.tr(),
+                  ),
+                ),
+              ),
+              BottomNavigationBar(
+                backgroundColor: Colors.black.withOpacity(0.4),
+                unselectedIconTheme: const IconThemeData(color: Colors.white),
+                selectedIconTheme: const IconThemeData(color: Colors.white),
+                unselectedLabelStyle: const TextStyle(color: Colors.black),
+                selectedLabelStyle: const TextStyle(color: Colors.black),
+                showSelectedLabels: false,
+                showUnselectedLabels: false,
+                items: [
+                  BottomNavigationBarItem(
+                    icon: const Icon(Icons.ios_share_rounded),
+                    label: 'control_bottom_app_bar_share'.tr(),
+                    tooltip: 'control_bottom_app_bar_share'.tr(),
+                  ),
+                  asset().isArchived
+                      ? BottomNavigationBarItem(
+                          icon: const Icon(Icons.unarchive_rounded),
+                          label: 'control_bottom_app_bar_unarchive'.tr(),
+                          tooltip: 'control_bottom_app_bar_unarchive'.tr(),
+                        )
+                      : BottomNavigationBarItem(
+                          icon: const Icon(Icons.archive_outlined),
+                          label: 'control_bottom_app_bar_archive'.tr(),
+                          tooltip: 'control_bottom_app_bar_archive'.tr(),
+                        ),
+                  BottomNavigationBarItem(
+                    icon: const Icon(Icons.delete_outline),
+                    label: 'control_bottom_app_bar_delete'.tr(),
+                    tooltip: 'control_bottom_app_bar_delete'.tr(),
+                  ),
+                ],
+                onTap: (index) {
+                  switch (index) {
+                    case 0:
+                      shareAsset();
+                      break;
+                    case 1:
+                      handleArchive(asset());
+                      break;
+                    case 2:
+                      handleDelete(asset());
+                      break;
+                  }
+                },
               ),
             ],
-            onTap: (index) {
-              switch (index) {
-                case 0:
-                  shareAsset();
-                  break;
-                case 1:
-                  handleArchive(asset());
-                  break;
-                case 2:
-                  handleDelete(asset());
-                  break;
-              }
-            },
           ),
         ),
       );
     }
 
+    ref.listen(showControlsProvider, (_, show) {
+      if (show) {
+        SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
+      } else {
+        SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
+      }
+    });
+
     ImageProvider imageProvider(Asset asset) {
       if (asset.isLocal) {
         return localImageProvider(asset);
@@ -405,7 +491,6 @@ class GalleryViewerPage extends HookConsumerWidget {
             PhotoViewGallery.builder(
               scaleStateChangedCallback: (state) {
                 isZoomed.value = state != PhotoViewScaleState.initial;
-                showAppBar.value = !isZoomed.value;
               },
               pageController: controller,
               scrollPhysics: isZoomed.value
@@ -426,6 +511,8 @@ class GalleryViewerPage extends HookConsumerWidget {
                   precacheNextImage(value - 1);
                 }
                 currentIndex.value = value;
+                progressValue.value = 0.0;
+
                 HapticFeedback.selectionClick();
               },
               loadingBuilder: isLoadPreview.value
@@ -493,8 +580,9 @@ class GalleryViewerPage extends HookConsumerWidget {
                         localPosition = details.localPosition,
                     onDragUpdate: (_, details, __) =>
                         handleSwipeUpDown(details),
-                    onTapDown: (_, __, ___) =>
-                        showAppBar.value = !showAppBar.value,
+                    onTapDown: (_, __, ___) {
+                      ref.read(showControlsProvider.notifier).toggle();
+                    },
                     imageProvider: provider,
                     heroAttributes: PhotoViewHeroAttributes(
                       tag: asset.id,
@@ -519,7 +607,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                     filterQuality: FilterQuality.high,
                     maxScale: 1.0,
                     minScale: 1.0,
-                    basePosition: Alignment.bottomCenter,
+                    basePosition: Alignment.center,
                     child: VideoViewerPage(
                       onPlaying: () => isPlayingVideo.value = true,
                       onPaused: () => isPlayingVideo.value = false,
@@ -559,4 +647,37 @@ class GalleryViewerPage extends HookConsumerWidget {
       ),
     );
   }
+
+  String _formatDuration(Duration position) {
+    final ms = position.inMilliseconds;
+
+    int seconds = ms ~/ 1000;
+    final int hours = seconds ~/ 3600;
+    seconds = seconds % 3600;
+    final minutes = seconds ~/ 60;
+    seconds = seconds % 60;
+
+    final hoursString = hours >= 10
+        ? '$hours'
+        : hours == 0
+            ? '00'
+            : '0$hours';
+
+    final minutesString = minutes >= 10
+        ? '$minutes'
+        : minutes == 0
+            ? '00'
+            : '0$minutes';
+
+    final secondsString = seconds >= 10
+        ? '$seconds'
+        : seconds == 0
+            ? '00'
+            : '0$seconds';
+
+    final formattedTime =
+        '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
+
+    return formattedTime;
+  }
 }

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

@@ -5,11 +5,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:chewie/chewie.dart';
 import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
+import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:video_player/video_player.dart';
+import 'package:wakelock/wakelock.dart';
 
 // ignore: must_be_immutable
 class VideoViewerPage extends HookConsumerWidget {
@@ -130,13 +132,16 @@ class _VideoPlayerState extends State<VideoPlayer> {
     videoPlayerController.addListener(() {
       if (videoPlayerController.value.isInitialized) {
         if (videoPlayerController.value.isPlaying) {
+          Wakelock.enable();
           widget.onPlaying?.call();
         } else if (!videoPlayerController.value.isPlaying) {
+          Wakelock.disable();
           widget.onPaused?.call();
         }
 
         if (videoPlayerController.value.position ==
             videoPlayerController.value.duration) {
+          Wakelock.disable();
           widget.onVideoEnded();
         }
       }
@@ -170,9 +175,10 @@ class _VideoPlayerState extends State<VideoPlayer> {
       videoPlayerController: videoPlayerController,
       autoPlay: true,
       autoInitialize: true,
-      allowFullScreen: true,
+      allowFullScreen: false,
       allowedScreenSleep: false,
       showControls: !widget.isMotionVideo,
+      customControls: const VideoPlayerControls(),
       hideControlsTimer: const Duration(seconds: 5),
     );
   }

+ 11 - 0
mobile/lib/modules/backup/services/backup.service.dart

@@ -16,7 +16,9 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:immich_mobile/utils/files_helper.dart';
 import 'package:isar/isar.dart';
+import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
+import 'package:permission_handler/permission_handler.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:http_parser/http_parser.dart';
 import 'package:path/path.dart' as p;
@@ -33,6 +35,7 @@ class BackupService {
   final httpClient = http.Client();
   final ApiService _apiService;
   final Isar _db;
+  final Logger _log = Logger("BackupService");
 
   BackupService(this._apiService, this._db);
 
@@ -203,6 +206,14 @@ class BackupService {
     Function(CurrentUploadAsset) setCurrentUploadAssetCb,
     Function(ErrorUploadAsset) errorCb,
   ) async {
+    if (Platform.isAndroid &&
+        !(await Permission.accessMediaLocation.status).isGranted) {
+      // double check that permission is granted here, to guard against
+      // uploading corrupt assets without EXIF information
+      _log.warning("Media location permission is not granted. "
+          "Cannot access original assets for backup.");
+      return false;
+    }
     final String deviceId = Store.get(StoreKey.deviceId);
     final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
     File? file;

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

@@ -29,8 +29,8 @@ class GroupDividerTitle extends ConsumerWidget {
 
     return Padding(
       padding: const EdgeInsets.only(
-        top: 29.0,
-        bottom: 10.0,
+        top: 12.0,
+        bottom: 4.0,
         left: 12.0,
         right: 12.0,
       ),

+ 3 - 0
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart

@@ -28,6 +28,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
   final bool showMultiSelectIndicator;
   final void Function(ItemPosition start, ItemPosition end)?
       visibleItemsListener;
+  final Widget? topWidget;
 
   const ImmichAssetGrid({
     super.key,
@@ -44,6 +45,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
     this.dynamicLayout,
     this.showMultiSelectIndicator = true,
     this.visibleItemsListener,
+    this.topWidget,
   });
 
   @override
@@ -125,6 +127,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
                   settings.getSetting(AppSettingsEnum.dynamicLayout),
               showMultiSelectIndicator: showMultiSelectIndicator,
               visibleItemsListener: visibleItemsListener,
+              topWidget: topWidget,
             ),
           ),
         ),

+ 7 - 3
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart

@@ -127,7 +127,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
           width: width * widthDistribution[index],
           height: width,
           margin: EdgeInsets.only(
-            top: widget.margin,
+            bottom: widget.margin,
             right: last ? 0.0 : widget.margin,
           ),
           child: _buildThumbnailOrPlaceholder(asset, absoluteOffset + index),
@@ -157,7 +157,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
     final String title = monthFormat.format(date);
     return Padding(
       key: Key("month-$title"),
-      padding: const EdgeInsets.only(left: 12.0, top: 30),
+      padding: const EdgeInsets.only(left: 12.0, top: 24.0),
       child: Text(
         title,
         style: TextStyle(
@@ -179,7 +179,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
             width: width,
             height: height,
             margin: EdgeInsets.only(
-              top: widget.margin,
+              bottom: widget.margin,
               right: i + 1 == num ? 0.0 : widget.margin,
             ),
             color: Colors.grey,
@@ -206,6 +206,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
           key: ValueKey(section.offset),
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
+            if (section.offset == 0 && widget.topWidget != null)
+              widget.topWidget!,
             if (section.type == RenderAssetGridElementType.monthTitle)
               _buildMonthTitle(context, section.date),
             if (section.type == RenderAssetGridElementType.groupDividerTitle ||
@@ -401,6 +403,7 @@ class ImmichAssetGridView extends StatefulWidget {
   final bool showMultiSelectIndicator;
   final void Function(ItemPosition start, ItemPosition end)?
       visibleItemsListener;
+  final Widget? topWidget;
 
   const ImmichAssetGridView({
     super.key,
@@ -416,6 +419,7 @@ class ImmichAssetGridView extends StatefulWidget {
     this.dynamicLayout = true,
     this.showMultiSelectIndicator = true,
     this.visibleItemsListener,
+    this.topWidget,
   });
 
   @override

+ 4 - 2
mobile/lib/modules/login/services/oauth.service.dart

@@ -1,4 +1,5 @@
 import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:flutter_web_auth/flutter_web_auth.dart';
 
@@ -7,7 +8,7 @@ import 'package:flutter_web_auth/flutter_web_auth.dart';
 class OAuthService {
   final ApiService _apiService;
   final callbackUrlScheme = 'app.immich';
-
+  final log = Logger('OAuthService');
   OAuthService(this._apiService);
 
   Future<OAuthConfigResponseDto?> getOAuthServerConfig(
@@ -33,7 +34,8 @@ class OAuthService {
           url: result,
         ),
       );
-    } catch (e) {
+    } catch (e, stack) {
+      log.severe("Error performing oAuthLogin: ${e.toString()}", e, stack);
       return null;
     }
   }

+ 26 - 16
mobile/lib/modules/onboarding/providers/gallery_permission.provider.dart

@@ -6,7 +6,7 @@ import 'package:permission_handler/permission_handler.dart';
 
 class GalleryPermissionNotifier extends StateNotifier<PermissionStatus> {
   GalleryPermissionNotifier()
-    : super(PermissionStatus.denied)  // Denied is the intitial state
+      : super(PermissionStatus.denied) // Denied is the intitial state
   {
     // Sets the initial state
     getGalleryPermissionStatus();
@@ -16,19 +16,20 @@ class GalleryPermissionNotifier extends StateNotifier<PermissionStatus> {
 
   /// Requests the gallery permission
   Future<PermissionStatus> requestGalleryPermission() async {
+    PermissionStatus result;
     // Android 32 and below uses Permission.storage
     if (Platform.isAndroid) {
       final androidInfo = await DeviceInfoPlugin().androidInfo;
       if (androidInfo.version.sdkInt <= 32) {
         // Android 32 and below need storage
         final permission = await Permission.storage.request();
-        state = permission;
-        return permission;
+        result = permission;
       } else {
         // Android 33 need photo & video
         final photos = await Permission.photos.request();
         if (!photos.isGranted) {
           // Don't ask twice for the same permission
+          state = photos;
           return photos;
         }
         final videos = await Permission.videos.request();
@@ -45,28 +46,32 @@ class GalleryPermissionNotifier extends StateNotifier<PermissionStatus> {
           status = PermissionStatus.denied;
         }
 
-        state = status;
-        return status;
+        result = status;
+      }
+      if (result == PermissionStatus.granted &&
+          androidInfo.version.sdkInt >= 29) {
+        result = await Permission.accessMediaLocation.request();
       }
     } else {
       // iOS can use photos
       final photos = await Permission.photos.request();
-      state = photos;
-      return photos;
+      result = photos;
     }
+    state = result;
+    return result;
   }
 
   /// Checks the current state of the gallery permissions without
   /// requesting them again
   Future<PermissionStatus> getGalleryPermissionStatus() async {
+    PermissionStatus result;
     // Android 32 and below uses Permission.storage
     if (Platform.isAndroid) {
       final androidInfo = await DeviceInfoPlugin().androidInfo;
       if (androidInfo.version.sdkInt <= 32) {
         // Android 32 and below need storage
         final permission = await Permission.storage.status;
-        state = permission;
-        return permission;
+        result = permission;
       } else {
         // Android 33 needs photo & video
         final photos = await Permission.photos.status;
@@ -84,18 +89,23 @@ class GalleryPermissionNotifier extends StateNotifier<PermissionStatus> {
           status = PermissionStatus.denied;
         }
 
-        state = status;
-        return status;
+        result = status;
+      }
+      if (state == PermissionStatus.granted &&
+          androidInfo.version.sdkInt >= 29) {
+        result = await Permission.accessMediaLocation.status;
       }
     } else {
       // iOS can use photos
       final photos = await Permission.photos.status;
-      state = photos;
-      return photos;
+      result = photos;
     }
+    state = result;
+    return result;
   }
 }
 
-final galleryPermissionNotifier
-  = StateNotifierProvider<GalleryPermissionNotifier, PermissionStatus>
-    ((ref) => GalleryPermissionNotifier());
+final galleryPermissionNotifier =
+    StateNotifierProvider<GalleryPermissionNotifier, PermissionStatus>(
+  (ref) => GalleryPermissionNotifier(),
+);

+ 44 - 0
mobile/lib/modules/search/providers/people.provider.dart

@@ -0,0 +1,44 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
+import 'package:immich_mobile/modules/search/services/person.service.dart';
+import 'package:openapi/api.dart';
+
+final personAssetsProvider = FutureProvider.family
+    .autoDispose<RenderList, String>((ref, personId) async {
+  final PersonService personService = ref.watch(personServiceProvider);
+
+  final assets = await personService.getPersonAssets(personId);
+
+  if (assets == null) {
+    return RenderList.empty();
+  }
+
+  return RenderList.fromAssets(assets, GroupAssetsBy.auto);
+});
+
+final getCuratedPeopleProvider =
+    FutureProvider.autoDispose<List<PersonResponseDto>>((ref) async {
+  final PersonService personService = ref.watch(personServiceProvider);
+
+  final curatedPeople = await personService.getCuratedPeople();
+
+  return curatedPeople ?? [];
+});
+
+class UpdatePersonName {
+  final String id;
+  final String name;
+
+  UpdatePersonName(this.id, this.name);
+}
+
+final updatePersonNameProvider =
+    StateProvider.family<void, UpdatePersonName>((ref, dto) async {
+  final PersonService personService = ref.watch(personServiceProvider);
+
+  final person = await personService.updateName(dto.id, dto.name);
+
+  if (person != null && person.name == dto.name) {
+    ref.invalidate(getCuratedPeopleProvider);
+  }
+});

+ 56 - 0
mobile/lib/modules/search/services/person.service.dart

@@ -0,0 +1,56 @@
+import 'package:flutter/material.dart';
+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/services/api.service.dart';
+import 'package:openapi/api.dart';
+
+final personServiceProvider = Provider(
+  (ref) => PersonService(
+    ref.watch(apiServiceProvider),
+  ),
+);
+
+class PersonService {
+  final ApiService _apiService;
+
+  PersonService(this._apiService);
+
+  Future<List<PersonResponseDto>?> getCuratedPeople() async {
+    try {
+      return await _apiService.personApi.getAllPeople();
+    } catch (e) {
+      debugPrint("Error [getCuratedPeople] ${e.toString()}");
+      return null;
+    }
+  }
+
+  Future<List<Asset>?> getPersonAssets(String id) async {
+    try {
+      final assets = await _apiService.personApi.getPersonAssets(id);
+
+      if (assets == null) {
+        return null;
+      }
+
+      return assets.map((e) => Asset.remote(e)).toList();
+    } catch (e) {
+      debugPrint("Error [getPersonAssets] ${e.toString()}");
+      return null;
+    }
+  }
+
+  Future<PersonResponseDto?> updateName(String id, String name) async {
+    try {
+      return await _apiService.personApi.updatePerson(
+        id,
+        PersonUpdateDto(
+          name: name,
+        ),
+      );
+    } catch (e) {
+      debugPrint("Error [updateName] ${e.toString()}");
+      return null;
+    }
+  }
+}

+ 114 - 0
mobile/lib/modules/search/ui/curated_people_row.dart

@@ -0,0 +1,114 @@
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/modules/search/models/curated_content.dart';
+import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
+
+class CuratedPeopleRow extends StatelessWidget {
+  final List<CuratedContent> content;
+
+  /// Callback with the content and the index when tapped
+  final Function(CuratedContent, int)? onTap;
+  final Function(CuratedContent, int)? onNameTap;
+
+  const CuratedPeopleRow({
+    super.key,
+    required this.content,
+    this.onTap,
+    required this.onNameTap,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    const imageSize = 85.0;
+
+    // Guard empty [content]
+    if (content.isEmpty) {
+      // Return empty thumbnail
+      return Align(
+        alignment: Alignment.centerLeft,
+        child: Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 16.0),
+          child: SizedBox(
+            width: imageSize,
+            height: imageSize,
+            child: ThumbnailWithInfo(
+              textInfo: '',
+              onTap: () {},
+            ),
+          ),
+        ),
+      );
+    }
+
+    return ListView.builder(
+      scrollDirection: Axis.horizontal,
+      padding: const EdgeInsets.only(
+        left: 16,
+        top: 8,
+      ),
+      itemBuilder: (context, index) {
+        final person = content[index];
+        final headers = {
+          "Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
+        };
+        return Padding(
+          padding: const EdgeInsets.only(right: 18.0),
+          child: SizedBox(
+            width: imageSize,
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.center,
+              children: [
+                GestureDetector(
+                  onTap: () => onTap?.call(person, index),
+                  child: SizedBox(
+                    height: imageSize,
+                    child: Material(
+                      shape: const CircleBorder(side: BorderSide.none),
+                      elevation: 3,
+                      child: CircleAvatar(
+                        maxRadius: imageSize / 2,
+                        backgroundImage: NetworkImage(
+                          getFaceThumbnailUrl(person.id),
+                          headers: headers,
+                        ),
+                      ),
+                    ),
+                  ),
+                ),
+                if (person.label == "")
+                  GestureDetector(
+                    onTap: () => onNameTap?.call(person, index),
+                    child: Padding(
+                      padding: const EdgeInsets.only(top: 8.0),
+                      child: Text(
+                        "Add name",
+                        style: TextStyle(
+                          fontWeight: FontWeight.bold,
+                          color: Theme.of(context).primaryColor,
+                        ),
+                      ),
+                    ),
+                  )
+                else
+                  Padding(
+                    padding: const EdgeInsets.only(top: 8.0),
+                    child: Text(
+                      person.label,
+                      textAlign: TextAlign.center,
+                      overflow: TextOverflow.ellipsis,
+                      style: const TextStyle(
+                        fontWeight: FontWeight.bold,
+                        fontSize: 13.0,
+                      ),
+                    ),
+                  )
+              ],
+            ),
+          ),
+        );
+      },
+      itemCount: content.length,
+    );
+  }
+}

+ 18 - 5
mobile/lib/modules/search/ui/explore_grid.dart

@@ -4,12 +4,16 @@ import 'package:immich_mobile/modules/search/models/curated_content.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:immich_mobile/utils/image_url_builder.dart';
 
 class ExploreGrid extends StatelessWidget {
   final List<CuratedContent> curatedContent;
+  final bool isPeople;
+
   const ExploreGrid({
     super.key,
     required this.curatedContent,
+    this.isPeople = false,
   });
 
   @override
@@ -36,16 +40,25 @@ class ExploreGrid extends StatelessWidget {
       ),
       itemBuilder: (context, index) {
         final content = curatedContent[index];
-        final thumbnailRequestUrl =
-            '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${content.id}';
+        final thumbnailRequestUrl = isPeople
+            ? getFaceThumbnailUrl(content.id)
+            : '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${content.id}';
+
         return ThumbnailWithInfo(
           imageUrl: thumbnailRequestUrl,
           textInfo: content.label,
           borderRadius: 0,
           onTap: () {
-            AutoRouter.of(context).push(
-              SearchResultRoute(searchTerm: 'm:${content.label}'),
-            );
+            isPeople
+                ? AutoRouter.of(context).push(
+                    PersonResultRoute(
+                      personId: content.id,
+                      personName: content.label,
+                    ),
+                  )
+                : AutoRouter.of(context).push(
+                    SearchResultRoute(searchTerm: 'm:${content.label}'),
+                  );
           },
         );
       },

+ 82 - 0
mobile/lib/modules/search/ui/person_name_edit_form.dart

@@ -0,0 +1,82 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/search/providers/people.provider.dart';
+
+class PersonNameEditFormResult {
+  final bool success;
+  final String updatedName;
+
+  PersonNameEditFormResult(this.success, this.updatedName);
+}
+
+class PersonNameEditForm extends HookConsumerWidget {
+  final String personId;
+  final String personName;
+
+  const PersonNameEditForm({
+    super.key,
+    required this.personId,
+    required this.personName,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final controller = useTextEditingController(text: personName);
+
+    return AlertDialog(
+      title: const Text(
+        "Add a name",
+        style: TextStyle(fontWeight: FontWeight.bold),
+      ),
+      content: SingleChildScrollView(
+        child: TextFormField(
+          controller: controller,
+          autofocus: true,
+          decoration: const InputDecoration(
+            hintText: 'Name',
+          ),
+        ),
+      ),
+      actions: [
+        TextButton(
+          style: TextButton.styleFrom(),
+          onPressed: () {
+            Navigator.of(context, rootNavigator: true)
+                .pop<PersonNameEditFormResult>(
+              PersonNameEditFormResult(false, ''),
+            );
+          },
+          child: Text(
+            "Cancel",
+            style: TextStyle(
+              color: Colors.red[300],
+              fontWeight: FontWeight.bold,
+            ),
+          ),
+        ),
+        TextButton(
+          onPressed: () {
+            ref.read(
+              updatePersonNameProvider(
+                UpdatePersonName(personId, controller.text),
+              ),
+            );
+
+            Navigator.of(context, rootNavigator: true)
+                .pop<PersonNameEditFormResult>(
+              PersonNameEditFormResult(true, controller.text),
+            );
+          },
+          child: Text(
+            "Save",
+            style: TextStyle(
+              color: Theme.of(context).primaryColor,
+              fontWeight: FontWeight.bold,
+            ),
+          ),
+        ),
+      ],
+    );
+  }
+}

+ 46 - 0
mobile/lib/modules/search/ui/search_row_title.dart

@@ -0,0 +1,46 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+
+class SearchRowTitle extends StatelessWidget {
+  final Function() onViewAllPressed;
+  final String title;
+  final double top;
+
+  const SearchRowTitle({
+    super.key,
+    required this.onViewAllPressed,
+    required this.title,
+    this.top = 12,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: EdgeInsets.only(
+        left: 16.0,
+        right: 16.0,
+        top: top,
+      ),
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        children: [
+          Text(
+            title,
+            style: Theme.of(context).textTheme.titleSmall,
+          ),
+          TextButton(
+            onPressed: onViewAllPressed,
+            child: Text(
+              'search_page_view_all_button',
+              style: TextStyle(
+                color: Theme.of(context).primaryColor,
+                fontWeight: FontWeight.bold,
+                fontSize: 14.0,
+              ),
+            ).tr(),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 51 - 0
mobile/lib/modules/search/views/all_people_page.dart

@@ -0,0 +1,51 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/search/models/curated_content.dart';
+import 'package:immich_mobile/modules/search/providers/people.provider.dart';
+import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+
+class AllPeoplePage extends HookConsumerWidget {
+  const AllPeoplePage({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final curatedPeople = ref.watch(getCuratedPeopleProvider);
+
+    return Scaffold(
+      appBar: AppBar(
+        title: Text(
+          'all_people_page_title',
+          style: TextStyle(
+            color: Theme.of(context).primaryColor,
+            fontWeight: FontWeight.bold,
+            fontSize: 16.0,
+          ),
+        ).tr(),
+        leading: IconButton(
+          onPressed: () => AutoRouter.of(context).pop(),
+          icon: const Icon(Icons.arrow_back_ios_rounded),
+        ),
+      ),
+      body: curatedPeople.when(
+        loading: () => const Center(child: ImmichLoadingIndicator()),
+        error: (err, stack) => Center(
+          child: Text('Error: $err'),
+        ),
+        data: (people) => ExploreGrid(
+          isPeople: true,
+          curatedContent: people
+              .map(
+                (person) => CuratedContent(
+                  label: person.name,
+                  id: person.id,
+                ),
+              )
+              .toList(),
+        ),
+      ),
+    );
+  }
+}

+ 152 - 0
mobile/lib/modules/search/views/person_result_page.dart

@@ -0,0 +1,152 @@
+import 'package:auto_route/auto_route.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/home/ui/asset_grid/immich_asset_grid.dart';
+import 'package:immich_mobile/modules/search/providers/people.provider.dart';
+import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
+import 'package:immich_mobile/shared/models/store.dart' as isar_store;
+import 'package:immich_mobile/utils/image_url_builder.dart';
+
+class PersonResultPage extends HookConsumerWidget {
+  final String personId;
+  final String personName;
+
+  const PersonResultPage({
+    super.key,
+    required this.personId,
+    required this.personName,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final name = useState(personName);
+
+    showEditNameDialog() {
+      showDialog<PersonNameEditFormResult>(
+        context: context,
+        builder: (BuildContext context) {
+          return PersonNameEditForm(
+            personId: personId,
+            personName: personName,
+          );
+        },
+      ).then((result) {
+        if (result != null && result.success) {
+          name.value = result.updatedName;
+        }
+      });
+    }
+
+    void buildBottomSheet() {
+      showModalBottomSheet(
+        backgroundColor: Theme.of(context).scaffoldBackgroundColor,
+        isScrollControlled: false,
+        context: context,
+        useSafeArea: true,
+        builder: (context) {
+          return SafeArea(
+            child: Column(
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                ListTile(
+                  leading: const Icon(Icons.edit_outlined),
+                  title: const Text(
+                    'Edit name',
+                    style: TextStyle(fontWeight: FontWeight.bold),
+                  ),
+                  onTap: showEditNameDialog,
+                )
+              ],
+            ),
+          );
+        },
+      );
+    }
+
+    buildTitleBlock() {
+      if (name.value == "") {
+        return GestureDetector(
+          onTap: showEditNameDialog,
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text(
+                'Add a name',
+                style: Theme.of(context).textTheme.titleSmall?.copyWith(
+                      color: Theme.of(context).colorScheme.secondary,
+                    ),
+              ),
+              Text(
+                'Find them fast by name with search',
+                style: Theme.of(context).textTheme.labelSmall,
+              ),
+            ],
+          ),
+        );
+      }
+
+      return Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Text(
+            name.value,
+            style: Theme.of(context).textTheme.titleLarge,
+          ),
+        ],
+      );
+    }
+
+    return Scaffold(
+      appBar: AppBar(
+        title: Text(name.value),
+        leading: IconButton(
+          onPressed: () => AutoRouter.of(context).pop(),
+          icon: const Icon(Icons.arrow_back_ios_rounded),
+        ),
+        actions: [
+          IconButton(
+            onPressed: buildBottomSheet,
+            icon: const Icon(Icons.more_vert_rounded),
+          ),
+        ],
+      ),
+      body: ref.watch(personAssetsProvider(personId)).when(
+            loading: () => const Center(child: CircularProgressIndicator()),
+            error: (error, stackTrace) => Center(
+              child: Text(
+                error.toString(),
+              ),
+            ),
+            data: (data) => data.isEmpty
+                ? const Center(
+                    child: Text('Opps'),
+                  )
+                : ImmichAssetGrid(
+                    renderList: data,
+                    topWidget: Padding(
+                      padding: const EdgeInsets.only(left: 8.0, top: 24),
+                      child: Row(
+                        children: [
+                          CircleAvatar(
+                            radius: 36,
+                            backgroundImage: NetworkImage(
+                              getFaceThumbnailUrl(personId),
+                              headers: {
+                                "Authorization":
+                                    "Bearer ${isar_store.Store.get(isar_store.StoreKey.accessToken)}"
+                              },
+                            ),
+                          ),
+                          Padding(
+                            padding: const EdgeInsets.only(left: 16.0),
+                            child: buildTitleBlock(),
+                          ),
+                        ],
+                      ),
+                    ),
+                  ),
+          ),
+    );
+  }
+}

+ 67 - 59
mobile/lib/modules/search/views/search_page.dart

@@ -4,13 +4,16 @@ import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/search/models/curated_content.dart';
+import 'package:immich_mobile/modules/search/providers/people.provider.dart';
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
+import 'package:immich_mobile/modules/search/ui/curated_people_row.dart';
 import 'package:immich_mobile/modules/search/ui/curated_row.dart';
 import 'package:immich_mobile/modules/search/ui/immich_search_bar.dart';
+import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
+import 'package:immich_mobile/modules/search/ui/search_row_title.dart';
 import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
-import 'package:openapi/api.dart';
 
 // ignore: must_be_immutable
 class SearchPage extends HookConsumerWidget {
@@ -21,10 +24,9 @@ class SearchPage extends HookConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
-    AsyncValue<List<CuratedLocationsResponseDto>> curatedLocation =
-        ref.watch(getCuratedLocationProvider);
-    AsyncValue<List<CuratedObjectsResponseDto>> curatedObjects =
-        ref.watch(getCuratedObjectProvider);
+    final curatedLocation = ref.watch(getCuratedLocationProvider);
+    final curatedObjects = ref.watch(getCuratedObjectProvider);
+    final curatedPeople = ref.watch(getCuratedPeopleProvider);
     var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
     double imageSize = MediaQuery.of(context).size.width / 3;
 
@@ -54,6 +56,50 @@ class SearchPage extends HookConsumerWidget {
       );
     }
 
+    showNameEditModel(
+      String personId,
+      String personName,
+    ) {
+      return showDialog(
+        context: context,
+        builder: (BuildContext context) {
+          return PersonNameEditForm(personId: personId, personName: personName);
+        },
+      );
+    }
+
+    buildPeople() {
+      return SizedBox(
+        height: MediaQuery.of(context).size.width / 3,
+        child: curatedPeople.when(
+          loading: () => const Center(child: ImmichLoadingIndicator()),
+          error: (err, stack) => Center(child: Text('Error: $err')),
+          data: (people) => CuratedPeopleRow(
+            content: people
+                .map(
+                  (person) => CuratedContent(
+                    id: person.id,
+                    label: person.name,
+                  ),
+                )
+                .take(12)
+                .toList(),
+            onTap: (content, index) {
+              AutoRouter.of(context).push(
+                PersonResultRoute(
+                  personId: content.id,
+                  personName: content.label,
+                ),
+              );
+            },
+            onNameTap: (person, index) => {
+              showNameEditModel(person.id, person.label),
+            },
+          ),
+        ),
+      );
+    }
+
     buildPlaces() {
       return SizedBox(
         height: imageSize,
@@ -130,63 +176,25 @@ class SearchPage extends HookConsumerWidget {
           children: [
             ListView(
               children: [
-                Padding(
-                  padding: const EdgeInsets.symmetric(
-                    horizontal: 16.0,
-                    vertical: 4.0,
+                SearchRowTitle(
+                  title: "search_page_people".tr(),
+                  onViewAllPressed: () => AutoRouter.of(context).push(
+                    const AllPeopleRoute(),
                   ),
-                  child: Row(
-                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
-                    children: [
-                      Text(
-                        "search_page_places",
-                        style: Theme.of(context).textTheme.titleSmall,
-                      ).tr(),
-                      TextButton(
-                        child: Text(
-                          'search_page_view_all_button',
-                          style: TextStyle(
-                            color: Theme.of(context).primaryColor,
-                            fontWeight: FontWeight.bold,
-                            fontSize: 14.0,
-                          ),
-                        ).tr(),
-                        onPressed: () => AutoRouter.of(context).push(
-                          const CuratedLocationRoute(),
-                        ),
-                      ),
-                    ],
+                ),
+                buildPeople(),
+                SearchRowTitle(
+                  title: "search_page_places".tr(),
+                  onViewAllPressed: () => AutoRouter.of(context).push(
+                    const CuratedLocationRoute(),
                   ),
+                  top: 0,
                 ),
                 buildPlaces(),
-                Padding(
-                  padding: const EdgeInsets.only(
-                    top: 24.0,
-                    bottom: 4.0,
-                    left: 16.0,
-                    right: 16.0,
-                  ),
-                  child: Row(
-                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
-                    children: [
-                      Text(
-                        "search_page_things",
-                        style: Theme.of(context).textTheme.titleSmall,
-                      ).tr(),
-                      TextButton(
-                        child: Text(
-                          'search_page_view_all_button',
-                          style: TextStyle(
-                            color: Theme.of(context).primaryColor,
-                            fontWeight: FontWeight.bold,
-                            fontSize: 14.0,
-                          ),
-                        ).tr(),
-                        onPressed: () => AutoRouter.of(context).push(
-                          const CuratedObjectRoute(),
-                        ),
-                      ),
-                    ],
+                SearchRowTitle(
+                  title: "search_page_things".tr(),
+                  onViewAllPressed: () => AutoRouter.of(context).push(
+                    const CuratedObjectRoute(),
                   ),
                 ),
                 buildThings(),
@@ -200,7 +208,7 @@ class SearchPage extends HookConsumerWidget {
                 ),
                 ListTile(
                   leading: Icon(
-                    Icons.favorite_border,
+                    Icons.star_outline,
                     color: categoryIconColor,
                   ),
                   title:

+ 12 - 2
mobile/lib/routing/router.dart

@@ -25,9 +25,11 @@ import 'package:immich_mobile/modules/login/views/login_page.dart';
 import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
 import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart';
 import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
+import 'package:immich_mobile/modules/search/views/all_people_page.dart';
 import 'package:immich_mobile/modules/search/views/all_videos_page.dart';
 import 'package:immich_mobile/modules/search/views/curated_location_page.dart';
 import 'package:immich_mobile/modules/search/views/curated_object_page.dart';
+import 'package:immich_mobile/modules/search/views/person_result_page.dart';
 import 'package:immich_mobile/modules/search/views/recently_added_page.dart';
 import 'package:immich_mobile/modules/search/views/search_page.dart';
 import 'package:immich_mobile/modules/search/views/search_result_page.dart';
@@ -37,8 +39,8 @@ import 'package:immich_mobile/routing/duplicate_guard.dart';
 import 'package:immich_mobile/routing/gallery_permission_guard.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/album.dart';
-import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/models/logger_message.model.dart';
+import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:immich_mobile/shared/views/app_log_detail_page.dart';
@@ -140,7 +142,15 @@ part 'router.gr.dart';
       ],
     ),
     AutoRoute(page: PartnerPage, guards: [AuthGuard, DuplicateGuard]),
-    AutoRoute(page: PartnerDetailPage, guards: [AuthGuard, DuplicateGuard])
+    AutoRoute(page: PartnerDetailPage, guards: [AuthGuard, DuplicateGuard]),
+    AutoRoute(
+      page: PersonResultPage,
+      guards: [
+        AuthGuard,
+        DuplicateGuard,
+      ],
+    ),
+    AutoRoute(page: AllPeoplePage, guards: [AuthGuard, DuplicateGuard]),
   ],
 )
 class AppRouter extends _$AppRouter {

+ 90 - 6
mobile/lib/routing/router.gr.dart

@@ -273,6 +273,23 @@ class _$AppRouter extends RootStackRouter {
         ),
       );
     },
+    PersonResultRoute.name: (routeData) {
+      final args = routeData.argsAs<PersonResultRouteArgs>();
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: PersonResultPage(
+          key: args.key,
+          personId: args.personId,
+          personName: args.personName,
+        ),
+      );
+    },
+    AllPeopleRoute.name: (routeData) {
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: const AllPeoplePage(),
+      );
+    },
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
         routeData: routeData,
@@ -556,6 +573,22 @@ class _$AppRouter extends RootStackRouter {
             duplicateGuard,
           ],
         ),
+        RouteConfig(
+          PersonResultRoute.name,
+          path: '/person-result-page',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
+        RouteConfig(
+          AllPeopleRoute.name,
+          path: '/all-people-page',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
       ];
 }
 
@@ -671,9 +704,9 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
     Key? key,
     required Asset asset,
     required bool isMotionVideo,
-    required dynamic onVideoEnded,
-    dynamic onPlaying,
-    dynamic onPaused,
+    required void Function() onVideoEnded,
+    void Function()? onPlaying,
+    void Function()? onPaused,
     Widget? placeholder,
   }) : super(
           VideoViewerRoute.name,
@@ -709,11 +742,11 @@ class VideoViewerRouteArgs {
 
   final bool isMotionVideo;
 
-  final dynamic onVideoEnded;
+  final void Function() onVideoEnded;
 
-  final dynamic onPlaying;
+  final void Function()? onPlaying;
 
-  final dynamic onPaused;
+  final void Function()? onPaused;
 
   final Widget? placeholder;
 
@@ -1197,6 +1230,57 @@ class PartnerDetailRouteArgs {
   }
 }
 
+/// generated route for
+/// [PersonResultPage]
+class PersonResultRoute extends PageRouteInfo<PersonResultRouteArgs> {
+  PersonResultRoute({
+    Key? key,
+    required String personId,
+    required String personName,
+  }) : super(
+          PersonResultRoute.name,
+          path: '/person-result-page',
+          args: PersonResultRouteArgs(
+            key: key,
+            personId: personId,
+            personName: personName,
+          ),
+        );
+
+  static const String name = 'PersonResultRoute';
+}
+
+class PersonResultRouteArgs {
+  const PersonResultRouteArgs({
+    this.key,
+    required this.personId,
+    required this.personName,
+  });
+
+  final Key? key;
+
+  final String personId;
+
+  final String personName;
+
+  @override
+  String toString() {
+    return 'PersonResultRouteArgs{key: $key, personId: $personId, personName: $personName}';
+  }
+}
+
+/// generated route for
+/// [AllPeoplePage]
+class AllPeopleRoute extends PageRouteInfo<void> {
+  const AllPeopleRoute()
+      : super(
+          AllPeopleRoute.name,
+          path: '/all-people-page',
+        );
+
+  static const String name = 'AllPeopleRoute';
+}
+
 /// generated route for
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {

+ 2 - 0
mobile/lib/routing/tab_navigation_observer.dart

@@ -1,6 +1,7 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/providers/album.provider.dart';
+import 'package:immich_mobile/modules/search/providers/people.provider.dart';
 
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
 import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
@@ -32,6 +33,7 @@ class TabNavigationObserver extends AutoRouterObserver {
       // Refresh Location State
       ref.invalidate(getCuratedLocationProvider);
       ref.invalidate(getCuratedObjectProvider);
+      ref.invalidate(getCuratedPeopleProvider);
     }
 
     if (route.name == 'SharingRoute') {

+ 2 - 0
mobile/lib/shared/services/api.service.dart

@@ -17,6 +17,7 @@ class ApiService {
   late SearchApi searchApi;
   late ServerInfoApi serverInfoApi;
   late PartnerApi partnerApi;
+  late PersonApi personApi;
 
   ApiService() {
     final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@@ -39,6 +40,7 @@ class ApiService {
     serverInfoApi = ServerInfoApi(_apiClient);
     searchApi = SearchApi(_apiClient);
     partnerApi = PartnerApi(_apiClient);
+    personApi = PersonApi(_apiClient);
   }
 
   Future<String> resolveAndSetEndpoint(String serverUrl) async {

+ 8 - 4
mobile/lib/shared/services/asset.service.dart

@@ -82,8 +82,12 @@ class AssetService {
         _db.writeTxn(() => _db.eTags.put(ETag(id: user.id, value: newETag)));
       }
       return assets.map(Asset.remote).toList();
-    } catch (e, stack) {
-      log.severe('Error while getting remote assets', e, stack);
+    } catch (error, stack) {
+      log.severe(
+        'Error while getting remote assets: ${error.toString()}',
+        error,
+        stack,
+      );
       return null;
     }
   }
@@ -100,8 +104,8 @@ class AssetService {
 
       return await _apiService.assetApi
           .deleteAsset(DeleteAssetDto(ids: payload));
-    } catch (e) {
-      debugPrint("Error getAllAsset  ${e.toString()}");
+    } catch (error, stack) {
+      log.severe("Error deleteAssets  ${error.toString()}", error, stack);
       return null;
     }
   }

+ 0 - 1
mobile/lib/shared/views/tab_controller_page.dart

@@ -112,7 +112,6 @@ class TabControllerPage extends HookConsumerWidget {
             ),
             selectedIcon: buildIcon(
               Icon(
-                size: 24,
                 Icons.photo_library,
                 color: Theme.of(context).primaryColor,
               ),

+ 4 - 0
mobile/lib/utils/image_url_builder.dart

@@ -59,3 +59,7 @@ String _getThumbnailUrl(
 }) {
   return '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$id?format=${type.value}';
 }
+
+String getFaceThumbnailUrl(final String personId) {
+  return '${Store.get(StoreKey.serverEndpoint)}/person/$personId/thumbnail';
+}

+ 48 - 0
mobile/lib/utils/immich_app_theme.dart

@@ -24,6 +24,10 @@ ThemeData base = ThemeData(
   chipTheme: const ChipThemeData(
     side: BorderSide.none,
   ),
+  sliderTheme: const SliderThemeData(
+    thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
+    trackHeight: 2.0,
+  ),
 );
 
 ThemeData immichLightTheme = ThemeData(
@@ -32,6 +36,8 @@ ThemeData immichLightTheme = ThemeData(
   primarySwatch: Colors.indigo,
   primaryColor: Colors.indigo,
   hintColor: Colors.indigo,
+  focusColor: Colors.indigo,
+  splashColor: Colors.indigo.withOpacity(0.15),
   fontFamily: 'WorkSans',
   scaffoldBackgroundColor: immichBackgroundColor,
   snackBarTheme: const SnackBarThemeData(
@@ -97,6 +103,7 @@ ThemeData immichLightTheme = ThemeData(
     ),
   ),
   chipTheme: base.chipTheme,
+  sliderTheme: base.sliderTheme,
   popupMenuTheme: const PopupMenuThemeData(
     shape: RoundedRectangleBorder(
       borderRadius: BorderRadius.all(Radius.circular(10)),
@@ -119,6 +126,26 @@ ThemeData immichLightTheme = ThemeData(
       ),
     ),
   ),
+  dialogTheme: const DialogTheme(
+    surfaceTintColor: Colors.transparent,
+  ),
+  inputDecorationTheme: const InputDecorationTheme(
+    focusedBorder: OutlineInputBorder(
+      borderSide: BorderSide(
+        color: Colors.indigo,
+      ),
+    ),
+    labelStyle: TextStyle(
+      color: Colors.indigo,
+    ),
+    hintStyle: TextStyle(
+      fontSize: 14.0,
+      fontWeight: FontWeight.normal,
+    ),
+  ),
+  textSelectionTheme: const TextSelectionThemeData(
+    cursorColor: Colors.indigo,
+  ),
 );
 
 ThemeData immichDarkTheme = ThemeData(
@@ -196,6 +223,7 @@ ThemeData immichDarkTheme = ThemeData(
     ),
   ),
   chipTheme: base.chipTheme,
+  sliderTheme: base.sliderTheme,
   popupMenuTheme: const PopupMenuThemeData(
     shape: RoundedRectangleBorder(
       borderRadius: BorderRadius.all(Radius.circular(10)),
@@ -217,4 +245,24 @@ ThemeData immichDarkTheme = ThemeData(
       ),
     ),
   ),
+  dialogTheme: const DialogTheme(
+    surfaceTintColor: Colors.transparent,
+  ),
+  inputDecorationTheme: InputDecorationTheme(
+    focusedBorder: OutlineInputBorder(
+      borderSide: BorderSide(
+        color: immichDarkThemePrimaryColor,
+      ),
+    ),
+    labelStyle: TextStyle(
+      color: immichDarkThemePrimaryColor,
+    ),
+    hintStyle: const TextStyle(
+      fontSize: 14.0,
+      fontWeight: FontWeight.normal,
+    ),
+  ),
+  textSelectionTheme: TextSelectionThemeData(
+    cursorColor: immichDarkThemePrimaryColor,
+  ),
 );

+ 1 - 1
mobile/openapi/README.md

@@ -3,7 +3,7 @@ Immich API
 
 This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
 
-- API version: 1.62.1
+- API version: 1.64.0
 - Build package: org.openapitools.codegen.languages.DartClientCodegen
 
 ## Requirements

+ 0 - 11
mobile/openapi/lib/model/add_assets_dto.dart

@@ -43,17 +43,6 @@ class AddAssetsDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AddAssetsDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AddAssetsDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AddAssetsDto(
         assetIds: json[r'assetIds'] is Iterable
             ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)

+ 1 - 12
mobile/openapi/lib/model/add_assets_response_dto.dart

@@ -53,7 +53,7 @@ class AddAssetsResponseDto {
     if (this.album != null) {
       json[r'album'] = this.album;
     } else {
-      // json[r'album'] = null;
+    //  json[r'album'] = null;
     }
     return json;
   }
@@ -65,17 +65,6 @@ class AddAssetsResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AddAssetsResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AddAssetsResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AddAssetsResponseDto(
         successfullyAdded: mapValueOfType<int>(json, r'successfullyAdded')!,
         alreadyInAlbum: json[r'alreadyInAlbum'] is Iterable

+ 0 - 11
mobile/openapi/lib/model/add_users_dto.dart

@@ -43,17 +43,6 @@ class AddUsersDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AddUsersDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AddUsersDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AddUsersDto(
         sharedUserIds: json[r'sharedUserIds'] is Iterable
             ? (json[r'sharedUserIds'] as Iterable).cast<String>().toList(growable: false)

+ 0 - 11
mobile/openapi/lib/model/admin_signup_response_dto.dart

@@ -67,17 +67,6 @@ class AdminSignupResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AdminSignupResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AdminSignupResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AdminSignupResponseDto(
         id: mapValueOfType<String>(json, r'id')!,
         email: mapValueOfType<String>(json, r'email')!,

+ 0 - 11
mobile/openapi/lib/model/album_count_response_dto.dart

@@ -55,17 +55,6 @@ class AlbumCountResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AlbumCountResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AlbumCountResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AlbumCountResponseDto(
         owned: mapValueOfType<int>(json, r'owned')!,
         shared: mapValueOfType<int>(json, r'shared')!,

+ 2 - 13
mobile/openapi/lib/model/album_response_dto.dart

@@ -102,7 +102,7 @@ class AlbumResponseDto {
     if (this.albumThumbnailAssetId != null) {
       json[r'albumThumbnailAssetId'] = this.albumThumbnailAssetId;
     } else {
-      // json[r'albumThumbnailAssetId'] = null;
+    //  json[r'albumThumbnailAssetId'] = null;
     }
       json[r'shared'] = this.shared;
       json[r'sharedUsers'] = this.sharedUsers;
@@ -111,7 +111,7 @@ class AlbumResponseDto {
     if (this.lastModifiedAssetTimestamp != null) {
       json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
     } else {
-      // json[r'lastModifiedAssetTimestamp'] = null;
+    //  json[r'lastModifiedAssetTimestamp'] = null;
     }
     return json;
   }
@@ -123,17 +123,6 @@ class AlbumResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AlbumResponseDto(
         assetCount: mapValueOfType<int>(json, r'assetCount')!,
         id: mapValueOfType<String>(json, r'id')!,

+ 0 - 11
mobile/openapi/lib/model/all_job_status_response_dto.dart

@@ -97,17 +97,6 @@ class AllJobStatusResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AllJobStatusResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AllJobStatusResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AllJobStatusResponseDto(
         thumbnailGeneration: JobStatusDto.fromJson(json[r'thumbnailGeneration'])!,
         metadataExtraction: JobStatusDto.fromJson(json[r'metadataExtraction'])!,

+ 1 - 12
mobile/openapi/lib/model/api_key_create_dto.dart

@@ -41,7 +41,7 @@ class APIKeyCreateDto {
     if (this.name != null) {
       json[r'name'] = this.name;
     } else {
-      // json[r'name'] = null;
+    //  json[r'name'] = null;
     }
     return json;
   }
@@ -53,17 +53,6 @@ class APIKeyCreateDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "APIKeyCreateDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "APIKeyCreateDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return APIKeyCreateDto(
         name: mapValueOfType<String>(json, r'name'),
       );

+ 0 - 11
mobile/openapi/lib/model/api_key_create_response_dto.dart

@@ -49,17 +49,6 @@ class APIKeyCreateResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "APIKeyCreateResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "APIKeyCreateResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return APIKeyCreateResponseDto(
         secret: mapValueOfType<String>(json, r'secret')!,
         apiKey: APIKeyResponseDto.fromJson(json[r'apiKey'])!,

+ 0 - 11
mobile/openapi/lib/model/api_key_response_dto.dart

@@ -61,17 +61,6 @@ class APIKeyResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "APIKeyResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "APIKeyResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return APIKeyResponseDto(
         id: mapValueOfType<String>(json, r'id')!,
         name: mapValueOfType<String>(json, r'name')!,

+ 0 - 11
mobile/openapi/lib/model/api_key_update_dto.dart

@@ -43,17 +43,6 @@ class APIKeyUpdateDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "APIKeyUpdateDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "APIKeyUpdateDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return APIKeyUpdateDto(
         name: mapValueOfType<String>(json, r'name')!,
       );

+ 0 - 11
mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart

@@ -43,17 +43,6 @@ class AssetBulkUploadCheckDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetBulkUploadCheckDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetBulkUploadCheckDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AssetBulkUploadCheckDto(
         assets: AssetBulkUploadCheckItem.listFromJson(json[r'assets']),
       );

+ 0 - 11
mobile/openapi/lib/model/asset_bulk_upload_check_item.dart

@@ -50,17 +50,6 @@ class AssetBulkUploadCheckItem {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetBulkUploadCheckItem[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetBulkUploadCheckItem[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AssetBulkUploadCheckItem(
         id: mapValueOfType<String>(json, r'id')!,
         checksum: mapValueOfType<String>(json, r'checksum')!,

+ 0 - 11
mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart

@@ -43,17 +43,6 @@ class AssetBulkUploadCheckResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetBulkUploadCheckResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetBulkUploadCheckResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AssetBulkUploadCheckResponseDto(
         results: AssetBulkUploadCheckResult.listFromJson(json[r'results']),
       );

+ 2 - 13
mobile/openapi/lib/model/asset_bulk_upload_check_result.dart

@@ -58,12 +58,12 @@ class AssetBulkUploadCheckResult {
     if (this.reason != null) {
       json[r'reason'] = this.reason;
     } else {
-      // json[r'reason'] = null;
+    //  json[r'reason'] = null;
     }
     if (this.assetId != null) {
       json[r'assetId'] = this.assetId;
     } else {
-      // json[r'assetId'] = null;
+    //  json[r'assetId'] = null;
     }
     return json;
   }
@@ -75,17 +75,6 @@ class AssetBulkUploadCheckResult {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetBulkUploadCheckResult[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetBulkUploadCheckResult[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AssetBulkUploadCheckResult(
         id: mapValueOfType<String>(json, r'id')!,
         action: AssetBulkUploadCheckResultActionEnum.fromJson(json[r'action'])!,

+ 0 - 11
mobile/openapi/lib/model/asset_count_by_time_bucket.dart

@@ -49,17 +49,6 @@ class AssetCountByTimeBucket {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetCountByTimeBucket[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetCountByTimeBucket[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AssetCountByTimeBucket(
         timeBucket: mapValueOfType<String>(json, r'timeBucket')!,
         count: mapValueOfType<int>(json, r'count')!,

+ 0 - 11
mobile/openapi/lib/model/asset_count_by_time_bucket_response_dto.dart

@@ -49,17 +49,6 @@ class AssetCountByTimeBucketResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetCountByTimeBucketResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetCountByTimeBucketResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AssetCountByTimeBucketResponseDto(
         totalCount: mapValueOfType<int>(json, r'totalCount')!,
         buckets: AssetCountByTimeBucket.listFromJson(json[r'buckets']),

+ 0 - 11
mobile/openapi/lib/model/asset_count_by_user_id_response_dto.dart

@@ -67,17 +67,6 @@ class AssetCountByUserIdResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetCountByUserIdResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetCountByUserIdResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AssetCountByUserIdResponseDto(
         audio: mapValueOfType<int>(json, r'audio')!,
         photos: mapValueOfType<int>(json, r'photos')!,

+ 0 - 11
mobile/openapi/lib/model/asset_file_upload_response_dto.dart

@@ -49,17 +49,6 @@ class AssetFileUploadResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetFileUploadResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetFileUploadResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AssetFileUploadResponseDto(
         id: mapValueOfType<String>(json, r'id')!,
         duplicate: mapValueOfType<bool>(json, r'duplicate')!,

+ 0 - 11
mobile/openapi/lib/model/asset_ids_dto.dart

@@ -43,17 +43,6 @@ class AssetIdsDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetIdsDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetIdsDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AssetIdsDto(
         assetIds: json[r'assetIds'] is Iterable
             ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)

+ 1 - 12
mobile/openapi/lib/model/asset_ids_response_dto.dart

@@ -47,7 +47,7 @@ class AssetIdsResponseDto {
     if (this.error != null) {
       json[r'error'] = this.error;
     } else {
-      // json[r'error'] = null;
+    //  json[r'error'] = null;
     }
     return json;
   }
@@ -59,17 +59,6 @@ class AssetIdsResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetIdsResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetIdsResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AssetIdsResponseDto(
         assetId: mapValueOfType<String>(json, r'assetId')!,
         success: mapValueOfType<bool>(json, r'success')!,

+ 5 - 16
mobile/openapi/lib/model/asset_response_dto.dart

@@ -162,7 +162,7 @@ class AssetResponseDto {
     if (this.thumbhash != null) {
       json[r'thumbhash'] = this.thumbhash;
     } else {
-      // json[r'thumbhash'] = null;
+    //  json[r'thumbhash'] = null;
     }
       json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String();
       json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String();
@@ -172,23 +172,23 @@ class AssetResponseDto {
     if (this.mimeType != null) {
       json[r'mimeType'] = this.mimeType;
     } else {
-      // json[r'mimeType'] = null;
+    //  json[r'mimeType'] = null;
     }
       json[r'duration'] = this.duration;
     if (this.exifInfo != null) {
       json[r'exifInfo'] = this.exifInfo;
     } else {
-      // json[r'exifInfo'] = null;
+    //  json[r'exifInfo'] = null;
     }
     if (this.smartInfo != null) {
       json[r'smartInfo'] = this.smartInfo;
     } else {
-      // json[r'smartInfo'] = null;
+    //  json[r'smartInfo'] = null;
     }
     if (this.livePhotoVideoId != null) {
       json[r'livePhotoVideoId'] = this.livePhotoVideoId;
     } else {
-      // json[r'livePhotoVideoId'] = null;
+    //  json[r'livePhotoVideoId'] = null;
     }
       json[r'tags'] = this.tags;
       json[r'people'] = this.people;
@@ -203,17 +203,6 @@ class AssetResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AssetResponseDto(
         type: AssetTypeEnum.fromJson(json[r'type'])!,
         id: mapValueOfType<String>(json, r'id')!,

+ 0 - 11
mobile/openapi/lib/model/auth_device_response_dto.dart

@@ -73,17 +73,6 @@ class AuthDeviceResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "AuthDeviceResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "AuthDeviceResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return AuthDeviceResponseDto(
         id: mapValueOfType<String>(json, r'id')!,
         createdAt: mapValueOfType<String>(json, r'createdAt')!,

+ 0 - 11
mobile/openapi/lib/model/change_password_dto.dart

@@ -49,17 +49,6 @@ class ChangePasswordDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "ChangePasswordDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "ChangePasswordDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return ChangePasswordDto(
         password: mapValueOfType<String>(json, r'password')!,
         newPassword: mapValueOfType<String>(json, r'newPassword')!,

+ 0 - 11
mobile/openapi/lib/model/check_duplicate_asset_dto.dart

@@ -49,17 +49,6 @@ class CheckDuplicateAssetDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "CheckDuplicateAssetDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "CheckDuplicateAssetDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return CheckDuplicateAssetDto(
         deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!,
         deviceId: mapValueOfType<String>(json, r'deviceId')!,

+ 1 - 12
mobile/openapi/lib/model/check_duplicate_asset_response_dto.dart

@@ -47,7 +47,7 @@ class CheckDuplicateAssetResponseDto {
     if (this.id != null) {
       json[r'id'] = this.id;
     } else {
-      // json[r'id'] = null;
+    //  json[r'id'] = null;
     }
     return json;
   }
@@ -59,17 +59,6 @@ class CheckDuplicateAssetResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "CheckDuplicateAssetResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "CheckDuplicateAssetResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return CheckDuplicateAssetResponseDto(
         isExist: mapValueOfType<bool>(json, r'isExist')!,
         id: mapValueOfType<String>(json, r'id'),

+ 0 - 11
mobile/openapi/lib/model/check_existing_assets_dto.dart

@@ -49,17 +49,6 @@ class CheckExistingAssetsDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "CheckExistingAssetsDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "CheckExistingAssetsDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return CheckExistingAssetsDto(
         deviceAssetIds: json[r'deviceAssetIds'] is Iterable
             ? (json[r'deviceAssetIds'] as Iterable).cast<String>().toList(growable: false)

+ 0 - 11
mobile/openapi/lib/model/check_existing_assets_response_dto.dart

@@ -43,17 +43,6 @@ class CheckExistingAssetsResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "CheckExistingAssetsResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "CheckExistingAssetsResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return CheckExistingAssetsResponseDto(
         existingIds: json[r'existingIds'] is Iterable
             ? (json[r'existingIds'] as Iterable).cast<String>().toList(growable: false)

+ 0 - 11
mobile/openapi/lib/model/create_album_dto.dart

@@ -55,17 +55,6 @@ class CreateAlbumDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "CreateAlbumDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "CreateAlbumDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return CreateAlbumDto(
         albumName: mapValueOfType<String>(json, r'albumName')!,
         sharedWithUserIds: json[r'sharedWithUserIds'] is Iterable

+ 0 - 11
mobile/openapi/lib/model/create_profile_image_response_dto.dart

@@ -49,17 +49,6 @@ class CreateProfileImageResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "CreateProfileImageResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "CreateProfileImageResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return CreateProfileImageResponseDto(
         userId: mapValueOfType<String>(json, r'userId')!,
         profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,

+ 0 - 11
mobile/openapi/lib/model/create_tag_dto.dart

@@ -49,17 +49,6 @@ class CreateTagDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "CreateTagDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "CreateTagDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return CreateTagDto(
         type: TagTypeEnum.fromJson(json[r'type'])!,
         name: mapValueOfType<String>(json, r'name')!,

+ 2 - 13
mobile/openapi/lib/model/create_user_dto.dart

@@ -64,12 +64,12 @@ class CreateUserDto {
     if (this.storageLabel != null) {
       json[r'storageLabel'] = this.storageLabel;
     } else {
-      // json[r'storageLabel'] = null;
+    //  json[r'storageLabel'] = null;
     }
     if (this.externalPath != null) {
       json[r'externalPath'] = this.externalPath;
     } else {
-      // json[r'externalPath'] = null;
+    //  json[r'externalPath'] = null;
     }
     return json;
   }
@@ -81,17 +81,6 @@ class CreateUserDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "CreateUserDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "CreateUserDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return CreateUserDto(
         email: mapValueOfType<String>(json, r'email')!,
         password: mapValueOfType<String>(json, r'password')!,

+ 0 - 11
mobile/openapi/lib/model/curated_locations_response_dto.dart

@@ -67,17 +67,6 @@ class CuratedLocationsResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "CuratedLocationsResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "CuratedLocationsResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return CuratedLocationsResponseDto(
         id: mapValueOfType<String>(json, r'id')!,
         city: mapValueOfType<String>(json, r'city')!,

+ 0 - 11
mobile/openapi/lib/model/curated_objects_response_dto.dart

@@ -67,17 +67,6 @@ class CuratedObjectsResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "CuratedObjectsResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "CuratedObjectsResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return CuratedObjectsResponseDto(
         id: mapValueOfType<String>(json, r'id')!,
         object: mapValueOfType<String>(json, r'object')!,

+ 0 - 11
mobile/openapi/lib/model/delete_asset_dto.dart

@@ -43,17 +43,6 @@ class DeleteAssetDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "DeleteAssetDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "DeleteAssetDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return DeleteAssetDto(
         ids: json[r'ids'] is Iterable
             ? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)

+ 0 - 11
mobile/openapi/lib/model/delete_asset_response_dto.dart

@@ -49,17 +49,6 @@ class DeleteAssetResponseDto {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "DeleteAssetResponseDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "DeleteAssetResponseDto[$key]" has a null value in JSON.');
-        });
-        return true;
-      }());
-
       return DeleteAssetResponseDto(
         status: DeleteAssetStatus.fromJson(json[r'status'])!,
         id: mapValueOfType<String>(json, r'id')!,

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