Browse Source

Merge remote-tracking branch 'origin/main' into dev/asset-access-control

Matthias Rupp 2 years ago
parent
commit
38cfdea855
100 changed files with 1869 additions and 757 deletions
  1. 2 2
      Makefile
  2. 3 1
      README.md
  3. 32 13
      docker/.env.example
  4. 0 12
      docs/blog/2019-05-28-first-blog-post.md
  5. 0 44
      docs/blog/2019-05-29-long-blog-post.md
  6. 0 20
      docs/blog/2021-08-01-mdx-blog-post.mdx
  7. BIN
      docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg
  8. 0 25
      docs/blog/2021-08-26-welcome/index.md
  9. 5 17
      docs/blog/authors.yml
  10. 114 0
      docs/blog/release-1.36/index.mdx
  11. BIN
      docs/docs/usage/img/authentik-redirect.png
  12. 11 3
      docs/docs/usage/oauth.md
  13. 1 1
      docs/docusaurus.config.js
  14. 2 2
      mobile/android/app/build.gradle
  15. 18 4
      mobile/android/app/src/main/AndroidManifest.xml
  16. 2 2
      mobile/android/fastlane/Fastfile
  17. 3 0
      mobile/android/fastlane/metadata/android/en-US/changelogs/55.txt
  18. 2 0
      mobile/android/fastlane/metadata/android/en-US/changelogs/56.txt
  19. 2 0
      mobile/android/fastlane/metadata/android/en-US/changelogs/57.txt
  20. 6 0
      mobile/android/fastlane/metadata/android/en-US/changelogs/58.txt
  21. 3 3
      mobile/android/fastlane/report.xml
  22. 5 2
      mobile/assets/i18n/en-US.json
  23. BIN
      mobile/fonts/Inconsolata-Regular.ttf
  24. 6 0
      mobile/ios/Podfile.lock
  25. 3 3
      mobile/ios/Runner.xcodeproj/project.pbxproj
  26. 2 2
      mobile/ios/Runner/Info.plist
  27. 23 0
      mobile/ios/ci_scripts/ci_post_clone.sh
  28. 1 1
      mobile/ios/fastlane/Fastfile
  29. 6 6
      mobile/ios/fastlane/report.xml
  30. 4 0
      mobile/lib/constants/hive_box.dart
  31. 7 0
      mobile/lib/main.dart
  32. 4 1
      mobile/lib/modules/album/services/album.service.dart
  33. 35 14
      mobile/lib/modules/album/ui/album_thumbnail_card.dart
  34. 12 12
      mobile/lib/modules/album/ui/album_viewer_appbar.dart
  35. 15 15
      mobile/lib/modules/album/ui/album_viewer_thumbnail.dart
  36. 5 5
      mobile/lib/modules/album/ui/month_group_title.dart
  37. 2 2
      mobile/lib/modules/album/ui/selection_thumbnail_image.dart
  38. 18 17
      mobile/lib/modules/album/views/album_viewer_page.dart
  39. 4 4
      mobile/lib/modules/album/views/asset_selection_page.dart
  40. 20 20
      mobile/lib/modules/album/views/create_album_page.dart
  41. 6 6
      mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart
  42. 6 7
      mobile/lib/modules/album/views/select_user_for_sharing_page.dart
  43. 4 4
      mobile/lib/modules/album/views/sharing_page.dart
  44. 3 1
      mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart
  45. 7 6
      mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart
  46. 0 1
      mobile/lib/modules/backup/background_service/background.service.dart
  47. 2 0
      mobile/lib/modules/backup/background_service/localization.dart
  48. 45 27
      mobile/lib/modules/backup/providers/backup.provider.dart
  49. 4 4
      mobile/lib/modules/backup/ui/album_info_card.dart
  50. 6 4
      mobile/lib/modules/backup/views/album_preview_page.dart
  51. 67 20
      mobile/lib/modules/backup/views/backup_album_selection_page.dart
  52. 22 22
      mobile/lib/modules/backup/views/backup_controller_page.dart
  53. 26 34
      mobile/lib/modules/home/services/asset.service.dart
  54. 3 1
      mobile/lib/modules/home/ui/immich_sliver_appbar.dart
  55. 31 6
      mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart
  56. 5 5
      mobile/lib/modules/home/ui/profile_drawer/profile_drawer_header.dart
  57. 2 2
      mobile/lib/modules/home/ui/profile_drawer/server_info_box.dart
  58. 59 9
      mobile/lib/modules/home/views/home_page.dart
  59. 7 3
      mobile/lib/modules/login/models/hive_saved_login_info.model.dart
  60. 6 3
      mobile/lib/modules/login/models/hive_saved_login_info.model.g.dart
  61. 82 67
      mobile/lib/modules/login/providers/authentication.provider.dart
  62. 6 0
      mobile/lib/modules/login/providers/oauth.provider.dart
  63. 39 0
      mobile/lib/modules/login/services/oauth.service.dart
  64. 207 21
      mobile/lib/modules/login/ui/login_form.dart
  65. 53 2
      mobile/lib/modules/login/views/login_page.dart
  66. 7 7
      mobile/lib/modules/search/views/search_page.dart
  67. 8 8
      mobile/lib/modules/search/views/search_result_page.dart
  68. 0 1
      mobile/lib/modules/settings/views/settings_page.dart
  69. 16 11
      mobile/lib/routing/router.dart
  70. 18 1
      mobile/lib/routing/router.gr.dart
  71. 34 0
      mobile/lib/shared/models/immich_logger_message.model.dart
  72. 53 0
      mobile/lib/shared/models/immich_logger_message.model.g.dart
  73. 52 18
      mobile/lib/shared/providers/asset.provider.dart
  74. 7 8
      mobile/lib/shared/providers/release_info.provider.dart
  75. 24 23
      mobile/lib/shared/providers/websocket.provider.dart
  76. 2 0
      mobile/lib/shared/services/api.service.dart
  77. 1 0
      mobile/lib/shared/services/cache.service.dart
  78. 95 0
      mobile/lib/shared/services/immich_logger.service.dart
  79. 7 3
      mobile/lib/shared/services/json_cache.dart
  80. 4 1
      mobile/lib/shared/ui/immich_loading_indicator.dart
  81. 6 5
      mobile/lib/shared/ui/immich_toast.dart
  82. 165 0
      mobile/lib/shared/views/app_log_page.dart
  83. 22 12
      mobile/lib/shared/views/splash_screen.dart
  84. 15 13
      mobile/lib/utils/bytes_units.dart
  85. 2 0
      mobile/lib/utils/immich_cache_info_repository.dart
  86. 53 0
      mobile/lib/utils/openapi_extensions.dart
  87. 8 0
      mobile/lib/utils/tuple.dart
  88. 7 3
      mobile/openapi/doc/AssetApi.md
  89. 2 2
      mobile/openapi/doc/AssetResponseDto.md
  90. 0 1
      mobile/openapi/doc/UsageByUserDto.md
  91. 1 1
      mobile/openapi/doc/UserResponseDto.md
  92. 17 3
      mobile/openapi/lib/api/asset_api.dart
  93. 60 73
      mobile/openapi/lib/model/asset_response_dto.dart
  94. 1 9
      mobile/openapi/lib/model/usage_by_user_dto.dart
  95. 7 2
      mobile/openapi/lib/model/user_response_dto.dart
  96. 17 10
      mobile/pubspec.lock
  97. 6 2
      mobile/pubspec.yaml
  98. 16 2
      server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts
  99. 13 5
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  100. 47 25
      server/apps/immich/src/api-v1/asset/asset.service.ts

+ 2 - 2
Makefile

@@ -2,7 +2,7 @@ dev:
 	rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
 	rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
 
 
 dev-new:
 dev-new:
-	rm -rf ./server/dist && docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
+	rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --remove-orphans
 
 
 dev-update:
 dev-update:
 	rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
 	rm -rf ./server/dist && docker-compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
@@ -26,4 +26,4 @@ prod-scale:
 	docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
 	docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
 
 
 api:
 api:
-	cd ./server && npm run api:generate
+	cd ./server && npm run api:generate

+ 3 - 1
README.md

@@ -72,7 +72,9 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
 | Search by metadata, objects and image tags  | Yes     | No  |
 | Search by metadata, objects and image tags  | Yes     | No  |
 | Administrative functions (user management)  | N/A     | Yes |
 | Administrative functions (user management)  | N/A     | Yes |
 | Background backup                           | Android | N/A |
 | Background backup                           | Android | N/A |
-| Virtual scroll                              | N/A     | Yes |
+| Virtual scroll                              | Yes     | Yes |
+| OAuth Support                               | Yes     | Yes |
+| LivePhotos Backup and Playback (iOS only)   | Yes     | Yes |
 
 
 # Support the project
 # Support the project
 
 

+ 32 - 13
docker/.env.example

@@ -23,7 +23,9 @@ REDIS_HOSTNAME=immich_redis
 # REDIS_SOCKET=
 # REDIS_SOCKET=
 
 
 ###################################################################################
 ###################################################################################
-# Upload File Config
+# Upload File Location
+#
+# This is the location where uploaded files are stored.
 ###################################################################################
 ###################################################################################
 
 
 UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
 UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
@@ -36,19 +38,17 @@ LOG_LEVEL=simple
 
 
 ###################################################################################
 ###################################################################################
 # JWT SECRET
 # JWT SECRET
-###################################################################################
-
+#
 # This JWT_SECRET is used to sign the authentication keys for user login
 # This JWT_SECRET is used to sign the authentication keys for user login
 # You should set it to a long randomly generated value
 # You should set it to a long randomly generated value
 # You can use this command to generate one: openssl rand -base64 128
 # You can use this command to generate one: openssl rand -base64 128
+###################################################################################
+
 JWT_SECRET=
 JWT_SECRET=
 
 
 ###################################################################################
 ###################################################################################
 # Reverse Geocoding
 # Reverse Geocoding
-####################################################################################
-
-# DISABLE_REVERSE_GEOCODING=false
-
+#
 # Reverse geocoding is done locally which has a small impact on memory usage
 # Reverse geocoding is done locally which has a small impact on memory usage
 # This memory usage can be altered by changing the REVERSE_GEOCODING_PRECISION variable
 # This memory usage can be altered by changing the REVERSE_GEOCODING_PRECISION variable
 # This ranges from 0-3 with 3 being the most precise
 # This ranges from 0-3 with 3 being the most precise
@@ -56,25 +56,44 @@ JWT_SECRET=
 # 2 - Cities > 1000 population: ~150MB RAM
 # 2 - Cities > 1000 population: ~150MB RAM
 # 1 - Cities > 5000 population: ~80MB RAM
 # 1 - Cities > 5000 population: ~80MB RAM
 # 0 - Cities > 15000 population: ~40MB RAM
 # 0 - Cities > 15000 population: ~40MB RAM
+####################################################################################
 
 
+# DISABLE_REVERSE_GEOCODING=false
 # REVERSE_GEOCODING_PRECISION=3
 # REVERSE_GEOCODING_PRECISION=3
 
 
 ####################################################################################
 ####################################################################################
 # WEB - Optional
 # WEB - Optional
-####################################################################################
-
+#
 # Custom message on the login page, should be written in HTML form.
 # Custom message on the login page, should be written in HTML form.
-# For example PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
+# For example:
+# PUBLIC_LOGIN_PAGE_MESSAGE="This is a demo instance of Immich.<br><br>Email: <i>demo@demo.de</i><br>Password: <i>demo</i>"
+####################################################################################
 
 
 PUBLIC_LOGIN_PAGE_MESSAGE=
 PUBLIC_LOGIN_PAGE_MESSAGE=
 
 
 ####################################################################################
 ####################################################################################
 # Alternative Service Addresses - Optional
 # Alternative Service Addresses - Optional
-####################################################################################
-
-# This is an advanced feature for users who may be running their immich services on different hosts. It will not change which address or port that services bind to within their containers, but it will change where other services look for their peers.
+#
+# This is an advanced feature for users who may be running their immich services on different hosts.
+# It will not change which address or port that services bind to within their containers, but it will change where other services look for their peers.
 # Note: immich-microservices is bound to 3002, but no references are made
 # Note: immich-microservices is bound to 3002, but no references are made
+####################################################################################
 
 
 # IMMICH_WEB_URL=http://immich-web:3000
 # IMMICH_WEB_URL=http://immich-web:3000
 # IMMICH_SERVER_URL=http://immich-server:3001
 # IMMICH_SERVER_URL=http://immich-server:3001
 # IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
 # IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
+
+####################################################################################
+# OAuth Setting - Optional
+#
+# These setting will enable OAuth login for your instance of Immich
+# Folow the instructions in the page https://immich.app/docs/usage/oauth to set up your OAuth provider
+####################################################################################
+
+# OAUTH_ENABLED=false
+# OAUTH_ISSUER_URL=
+# OAUTH_CLIENT_ID=
+# OAUTH_CLIENT_SECRET=
+# OAUTH_BUTTON_TEXT=Login with OAuth
+# OAUTH_AUTO_REGISTER=true
+# OAUTH_SCOPE="openid profile email"

+ 0 - 12
docs/blog/2019-05-28-first-blog-post.md

@@ -1,12 +0,0 @@
----
-slug: first-blog-post
-title: First Blog Post
-authors:
-  name: Gao Wei
-  title: Docusaurus Core Team
-  url: https://github.com/wgao19
-  image_url: https://github.com/wgao19.png
-tags: [hola, docusaurus]
----
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet

+ 0 - 44
docs/blog/2019-05-29-long-blog-post.md

@@ -1,44 +0,0 @@
----
-slug: long-blog-post
-title: Long Blog Post
-authors: endi
-tags: [hello, docusaurus]
----
-
-This is the summary of a very long blog post,
-
-Use a `<!--` `truncate` `-->` comment to limit blog post size in the list view.
-
-<!--truncate-->
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
-
-Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet

+ 0 - 20
docs/blog/2021-08-01-mdx-blog-post.mdx

@@ -1,20 +0,0 @@
----
-slug: mdx-blog-post
-title: MDX Blog Post
-authors: [slorber]
-tags: [docusaurus]
----
-
-Blog posts support [Docusaurus Markdown features](https://docusaurus.io/docs/markdown-features), such as [MDX](https://mdxjs.com/).
-
-:::tip
-
-Use the power of React to create interactive blog posts.
-
-```js
-<button onClick={() => alert('button clicked!')}>Click me!</button>
-```
-
-<button onClick={() => alert('button clicked!')}>Click me!</button>
-
-:::

BIN
docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg


+ 0 - 25
docs/blog/2021-08-26-welcome/index.md

@@ -1,25 +0,0 @@
----
-slug: welcome
-title: Welcome
-authors: [slorber, yangshun]
-tags: [facebook, hello, docusaurus]
----
-
-[Docusaurus blogging features](https://docusaurus.io/docs/blog) are powered by the [blog plugin](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog).
-
-Simply add Markdown files (or folders) to the `blog` directory.
-
-Regular blog authors can be added to `authors.yml`.
-
-The blog post date can be extracted from filenames, such as:
-
-- `2019-05-30-welcome.md`
-- `2019-05-30-welcome/index.md`
-
-A blog post folder can be convenient to co-locate blog post images:
-
-![Docusaurus Plushie](./docusaurus-plushie-banner.jpeg)
-
-The blog supports tags as well!
-
-**And if you don't want a blog**: just delete this directory, and use `blog: false` in your Docusaurus config.

+ 5 - 17
docs/blog/authors.yml

@@ -1,17 +1,5 @@
-endi:
-  name: Endilie Yacop Sucipto
-  title: Maintainer of Docusaurus
-  url: https://github.com/endiliey
-  image_url: https://github.com/endiliey.png
-
-yangshun:
-  name: Yangshun Tay
-  title: Front End Engineer @ Facebook
-  url: https://github.com/yangshun
-  image_url: https://github.com/yangshun.png
-
-slorber:
-  name: Sébastien Lorber
-  title: Docusaurus maintainer
-  url: https://sebastienlorber.com
-  image_url: https://github.com/slorber.png
+alextran:
+  name: Alex Tran
+  title: Maintainer of Immich
+  url: https://github.com/alextran1502
+  image_url: https://github.com/alextran1502.png

+ 114 - 0
docs/blog/release-1.36/index.mdx

@@ -0,0 +1,114 @@
+---
+slug: release-1-36
+title: Release v1.36.0
+authors: [alextran]
+tags: [release]
+date: 2022-11-10
+---
+
+Hello everyone, it is my pleasure to deliver the new release of Immich to you. The team has been working hard to bring you the new features and improvements. This release includes some big features that the community has been asking since the beginning of Immich. We hope you will enjoy it.
+
+Some notable features are:
+
+- [OAuth integration](#livephoto-ios-support-)
+- [LivePhoto support on iOS](#oauth-integration-)
+- User config system
+
+<!--truncate-->
+
+## LivePhoto iOS Support 🎉
+
+LivePhoto on iOS is now supported in Immich.
+
+The motion part will now be uploaded and can be played on the mobile app and the web.
+
+:::caution
+
+- The server and the app has to be on version **1.36.x** for the application to work correctly.
+- Previous uploaded photos will not be updated automatically, you will have to remove and reupload them if you want to keep the LivePhoto functionality.
+
+:::
+
+<img
+  src="https://media.giphy.com/media/fTrGceZd7t1ewi8ESc/giphy.gif"
+  width="100%"
+  style={{
+    borderRadius: "10px",
+    boxShadow:
+      "rgba(9, 30, 66, 0.25) 0px 1px 1px, rgba(9, 30, 66, 0.13) 0px 0px 1px 1px",
+  }}
+  title="LivePhoto playback on the web"
+/>
+
+## OAuth Integration 🎉
+
+I want to borrow this chance to express my gratitude to [@EnricoBilla](https://github.com/EnricoBilla), who has been the trailblazer for this feature since the beginning days of Immich. His PR has sparked ideas, suggestions, and discussion among the team member on how to integrate this feature successfully into the app. Thank you so much for your work and your time.
+
+OAuth is now integrated into the system. Please follow the guide [here](https://immich.app/docs/usage/oauth) to set up your OAuth integration
+
+After setting up the correct environment variables in the `.env` file, as shown below
+
+| Key                 | Type    | Default              | Description                                                               |
+| ------------------- | ------- | -------------------- | ------------------------------------------------------------------------- |
+| OAUTH_ENABLED       | boolean | false                | Enable/disable OAuth2                                                     |
+| OAUTH_ISSUER_URL    | URL     | (required)           | Required. Self-discovery URL for client                                   |
+| OAUTH_CLIENT_ID     | string  | (required)           | Required. Client ID                                                       |
+| OAUTH_CLIENT_SECRET | string  | (required)           | Required. Client Secret                                                   |
+| OAUTH_SCOPE         | string  | openid email profile | Full list of scopes to send with the request (space delimited)            |
+| OAUTH_AUTO_REGISTER | boolean | true                 | When true, will automatically register a user the first time they sign in |
+| OAUTH_BUTTON_TEXT   | string  | Login with OAuth     | Text for the OAuth button on the web                                      |
+
+```bash title="Authentik Example"
+OAUTH_ENABLED=true
+OAUTH_ISSUER_URL=http://10.1.15.216:9000/application/o/immich-test/
+OAUTH_CLIENT_ID=30596v8f78a4b6a97d5985c3076b6b4c4d12ddc33
+OAUTH_CLIENT_SECRET=50f1eafdec353b95b1c638db390db4ab67ef035a51212dbec2f56175e2eb272b5d572c099176e6fe116ecf47ffdd544bgdb9e2edc588307ee0339d25eeccd88
+OAUTH_BUTTON_TEXT=Login with Authentik
+```
+
+The web will have the option to sign in with OAuth.
+
+<img
+  src="https://user-images.githubusercontent.com/27055614/202923726-f43fa148-47f5-4182-8f29-b0b87e4586fa.png"
+  width="50%"
+  title="Web Sign in with OAuth"
+  style={{
+    borderRadius: "10px",
+    boxShadow:
+      "rgba(9, 30, 66, 0.25) 0px 1px 1px, rgba(9, 30, 66, 0.13) 0px 0px 1px 1px",
+  }}
+/>
+
+The mobile app will check if the server has OAuth enabled before displaying the OAuth
+sign-in button.
+
+<img
+  src="https://media.giphy.com/media/3iy3SaNkVYtlkEiw06/giphy.gif"
+  title="Mobile sign in with OAuth"
+  style={{
+    borderRadius: "10px",
+    boxShadow:
+      "rgba(9, 30, 66, 0.25) 0px 1px 1px, rgba(9, 30, 66, 0.13) 0px 0px 1px 1px",
+  }}
+/>
+
+## Support
+
+<img
+  src="https://media.giphy.com/media/LStqgGESXW8XnuCv5y/giphy.gif"
+  width="300"
+  style={{
+    borderRadius: "10px",
+    boxShadow:
+      "rgba(9, 30, 66, 0.25) 0px 1px 1px, rgba(9, 30, 66, 0.13) 0px 0px 1px 1px",
+  }}
+  title="Support the project"
+/>
+
+If you find the project helpful and it helps you in some ways, you can support the project [one time](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) or [monthly](https://github.com/sponsors/alextran1502) from GitHub Sponsor
+
+It is a great way to let me know that you want me to continue developing and working on this project for years to come.
+
+## Details
+
+For more details, please check out the [release note](https://github.com/immich-app/immich/releases/tag/v1.36.0_55-dev)

BIN
docs/docs/usage/img/authentik-redirect.png


+ 11 - 3
docs/docs/usage/oauth.md

@@ -28,9 +28,17 @@ Before enabling OAuth in Immich, a new client application needs to be configured
 
 
 2. Configure Redirect URIs/Origins
 2. Configure Redirect URIs/Origins
 
 
-   1. The **Sign-in redirect URIs** should include:
+  The **Sign-in redirect URIs** should include:
 
 
-      - All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`)
+  * All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`)
+  * Mobile app redirect URL `app.immich:/`
+  
+:::caution
+You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly. 
+
+**Authentik example**
+<img src={require('./img/authentik-redirect.png').default} title="Authentik Redirection URL" width="80%" />
+:::
 
 
 ## Enable OAuth
 ## Enable OAuth
 
 
@@ -41,7 +49,7 @@ Once you have a new OAuth client application configured, Immich can be configure
 | OAUTH_ENABLED       | boolean | false                | Enable/disable OAuth2                                                     |
 | OAUTH_ENABLED       | boolean | false                | Enable/disable OAuth2                                                     |
 | OAUTH_ISSUER_URL    | URL     | (required)           | Required. Self-discovery URL for client (from previous step)              |
 | OAUTH_ISSUER_URL    | URL     | (required)           | Required. Self-discovery URL for client (from previous step)              |
 | OAUTH_CLIENT_ID     | string  | (required)           | Required. Client ID (from previous step)                                  |
 | OAUTH_CLIENT_ID     | string  | (required)           | Required. Client ID (from previous step)                                  |
-| OAUTH_CLIENT_SECRET | string  | (required)           | Required. Client Secret (previous step                                    |
+| OAUTH_CLIENT_SECRET | string  | (required)           | Required. Client Secret (previous step)                                    |
 | OAUTH_SCOPE         | string  | openid email profile | Full list of scopes to send with the request (space delimited)            |
 | OAUTH_SCOPE         | string  | openid email profile | Full list of scopes to send with the request (space delimited)            |
 | OAUTH_AUTO_REGISTER | boolean | true                 | When true, will automatically register a user the first time they sign in |
 | OAUTH_AUTO_REGISTER | boolean | true                 | When true, will automatically register a user the first time they sign in |
 | OAUTH_BUTTON_TEXT   | string  | Login with OAuth     | Text for the OAuth button on the web                                      |
 | OAUTH_BUTTON_TEXT   | string  | Login with OAuth     | Text for the OAuth button on the web                                      |

+ 1 - 1
docs/docusaurus.config.js

@@ -80,7 +80,7 @@ const config = {
             position: "right",
             position: "right",
             label: "Documentation",
             label: "Documentation",
           },
           },
-          // { to: "/blog", label: "Blog", position: "right" },
+          { to: "/blog", label: "Blog", position: "right" },
           {
           {
             href: "https://github.com/immich-app/immich",
             href: "https://github.com/immich-app/immich",
             label: "GitHub",
             label: "GitHub",

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

@@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) {
 
 
 
 
 android {
 android {
-    compileSdkVersion flutter.compileSdkVersion
+    compileSdkVersion 33
 
 
     compileOptions {
     compileOptions {
         sourceCompatibility JavaVersion.VERSION_1_8
         sourceCompatibility JavaVersion.VERSION_1_8
@@ -52,7 +52,7 @@ android {
         // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
         // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
         applicationId "app.alextran.immich"
         applicationId "app.alextran.immich"
         minSdkVersion 23
         minSdkVersion 23
-        targetSdkVersion flutter.targetSdkVersion
+        targetSdkVersion 33
         versionCode flutterVersionCode.toInteger()
         versionCode flutterVersionCode.toInteger()
         versionName flutterVersionName
         versionName flutterVersionName
     }
     }

+ 18 - 4
mobile/android/app/src/main/AndroidManifest.xml

@@ -12,21 +12,35 @@
       </intent-filter>
       </intent-filter>
 
 
     </activity>
     </activity>
+
+
+    <activity
+      android:name="com.linusu.flutter_web_auth.CallbackActivity"
+      android:exported="true">
+      <intent-filter android:label="flutter_web_auth">
+        <action android:name="android.intent.action.VIEW" />
+        <category android:name="android.intent.category.DEFAULT" />
+        <category android:name="android.intent.category.BROWSABLE" />
+        <data android:scheme="app.immich" />
+      </intent-filter>
+    </activity>
     <!-- Don't delete the meta-data below.
     <!-- Don't delete the meta-data below.
              This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
              This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
     <meta-data android:name="flutterEmbedding" android:value="2" />
     <meta-data android:name="flutterEmbedding" android:value="2" />
     <!-- Disables default WorkManager initialization to use our custom initialization -->
     <!-- Disables default WorkManager initialization to use our custom initialization -->
     <provider
     <provider
-        android:name="androidx.startup.InitializationProvider"
-        android:authorities="${applicationId}.androidx-startup"
-        tools:node="remove">
-    </provider>
+      android:name="androidx.startup.InitializationProvider"
+      android:authorities="${applicationId}.androidx-startup"
+      tools:node="remove"></provider>
   </application>
   </application>
   <uses-permission android:name="android.permission.INTERNET" />
   <uses-permission android:name="android.permission.INTERNET" />
   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
   <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
   <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
   <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
   <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
   <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
   <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
+  <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <!-- If you want to read images-->
+  <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <!-- If you want to read videos-->
+  <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <!-- If you want to read audio-->
 
 
   <queries>
   <queries>
     <intent>
     <intent>

+ 2 - 2
mobile/android/fastlane/Fastfile

@@ -35,8 +35,8 @@ platform :android do
       task: 'bundle', 
       task: 'bundle', 
       build_type: 'Release',
       build_type: 'Release',
       properties: {
       properties: {
-        "android.injected.version.code" => 54,
-        "android.injected.version.name" => "1.35.0",
+        "android.injected.version.code" => 58,
+        "android.injected.version.name" => "1.37.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')
     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')

+ 3 - 0
mobile/android/fastlane/metadata/android/en-US/changelogs/55.txt

@@ -0,0 +1,3 @@
+* Added OAuth login option
+* Tidy-up dependencies, remove unused, replace rarely used ones
+* Added view LivePhotos feature

+ 2 - 0
mobile/android/fastlane/metadata/android/en-US/changelogs/56.txt

@@ -0,0 +1,2 @@
+* Fixed freezed splash screen
+* Fixed OIDC redirect but not logging in

+ 2 - 0
mobile/android/fastlane/metadata/android/en-US/changelogs/57.txt

@@ -0,0 +1,2 @@
+* Show human readable file size in detail view
+* Fix permission issue on Android 33

+ 6 - 0
mobile/android/fastlane/metadata/android/en-US/changelogs/58.txt

@@ -0,0 +1,6 @@
+* Use binary prefixes for data sizes
+* Fix not able to show device asset on Android 13
+* Use cached asset info if unchanged instead of downloading all assets
+* Add in-app logging
+* Add search mechanism to album selection page
+* Improve UI

+ 3 - 3
mobile/android/fastlane/report.xml

@@ -5,17 +5,17 @@
     
     
     
     
       
       
-      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000233">
+      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000201">
         
         
       </testcase>
       </testcase>
     
     
       
       
-      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="61.699536">
+      <testcase classname="fastlane.lanes" name="1: bundleRelease" time="63.132489">
         
         
       </testcase>
       </testcase>
     
     
       
       
-      <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="46.210553">
+      <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="38.15883">
         
         
       </testcase>
       </testcase>
     
     

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

@@ -17,7 +17,7 @@
   "backup_album_selection_page_albums_device": "Albums on device ({})",
   "backup_album_selection_page_albums_device": "Albums on device ({})",
   "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
   "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
   "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
   "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
-  "backup_album_selection_page_select_albums": "Select Albums",
+  "backup_album_selection_page_select_albums": "Select albums",
   "backup_album_selection_page_selection_info": "Selection Info",
   "backup_album_selection_page_selection_info": "Selection Info",
   "backup_album_selection_page_total_assets": "Total unique assets",
   "backup_album_selection_page_total_assets": "Total unique assets",
   "backup_all": "All",
   "backup_all": "All",
@@ -109,7 +109,9 @@
   "login_form_err_invalid_email": "Invalid Email",
   "login_form_err_invalid_email": "Invalid Email",
   "login_form_err_leading_whitespace": "Leading whitespace",
   "login_form_err_leading_whitespace": "Leading whitespace",
   "login_form_err_trailing_whitespace": "Trailing whitespace",
   "login_form_err_trailing_whitespace": "Trailing whitespace",
-  "login_form_failed_login": "Error logging you in, check server url, email and password",
+  "login_form_failed_login": "Error logging you in, check server URL, email and password",
+  "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL",
+  "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server",
   "login_form_label_email": "Email",
   "login_form_label_email": "Email",
   "login_form_label_password": "Password",
   "login_form_label_password": "Password",
   "login_form_password_hint": "password",
   "login_form_password_hint": "password",
@@ -118,6 +120,7 @@
   "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
   "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
   "profile_drawer_settings": "Settings",
   "profile_drawer_settings": "Settings",
   "profile_drawer_sign_out": "Sign Out",
   "profile_drawer_sign_out": "Sign Out",
+  "profile_drawer_app_logs": "Logs",
   "search_bar_hint": "Search your photos",
   "search_bar_hint": "Search your photos",
   "search_page_no_objects": "No Objects Info Available",
   "search_page_no_objects": "No Objects Info Available",
   "search_page_no_places": "No Places Info Available",
   "search_page_no_places": "No Places Info Available",

BIN
mobile/fonts/Inconsolata-Regular.ttf


+ 6 - 0
mobile/ios/Podfile.lock

@@ -3,6 +3,8 @@ PODS:
   - flutter_udid (0.0.1):
   - flutter_udid (0.0.1):
     - Flutter
     - Flutter
     - SAMKeychain
     - SAMKeychain
+  - flutter_web_auth (0.5.0):
+    - Flutter
   - fluttertoast (0.0.2):
   - fluttertoast (0.0.2):
     - Flutter
     - Flutter
     - Toast
     - Toast
@@ -37,6 +39,7 @@ PODS:
 DEPENDENCIES:
 DEPENDENCIES:
   - Flutter (from `Flutter`)
   - Flutter (from `Flutter`)
   - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
   - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
+  - flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
   - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
   - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
   - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
   - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
   - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
@@ -60,6 +63,8 @@ EXTERNAL SOURCES:
     :path: Flutter
     :path: Flutter
   flutter_udid:
   flutter_udid:
     :path: ".symlinks/plugins/flutter_udid/ios"
     :path: ".symlinks/plugins/flutter_udid/ios"
+  flutter_web_auth:
+    :path: ".symlinks/plugins/flutter_web_auth/ios"
   fluttertoast:
   fluttertoast:
     :path: ".symlinks/plugins/fluttertoast/ios"
     :path: ".symlinks/plugins/fluttertoast/ios"
   image_picker_ios:
   image_picker_ios:
@@ -86,6 +91,7 @@ EXTERNAL SOURCES:
 SPEC CHECKSUMS:
 SPEC CHECKSUMS:
   Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
   Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
   flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
   flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
+  flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
   fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
   fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
   FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
   FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
   image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
   image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb

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

@@ -360,7 +360,7 @@
 				CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
 				CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 68;
+				CURRENT_PROJECT_VERSION = 73;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
 				INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,7 @@
 				CLANG_ENABLE_MODULES = YES;
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 68;
+				CURRENT_PROJECT_VERSION = 73;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
 				INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
 				CLANG_ENABLE_MODULES = YES;
 				CLANG_ENABLE_MODULES = YES;
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 68;
+				CURRENT_PROJECT_VERSION = 73;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				DEVELOPMENT_TEAM = 2F67MQ8R79;
 				ENABLE_BITCODE = NO;
 				ENABLE_BITCODE = NO;
 				INFOPLIST_FILE = Runner/Info.plist;
 				INFOPLIST_FILE = Runner/Info.plist;

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

@@ -17,11 +17,11 @@
     <key>CFBundlePackageType</key>
     <key>CFBundlePackageType</key>
     <string>APPL</string>
     <string>APPL</string>
     <key>CFBundleShortVersionString</key>
     <key>CFBundleShortVersionString</key>
-    <string>1.34.0</string>
+    <string>1.37.0</string>
     <key>CFBundleSignature</key>
     <key>CFBundleSignature</key>
     <string>????</string>
     <string>????</string>
     <key>CFBundleVersion</key>
     <key>CFBundleVersion</key>
-    <string>68</string>
+    <string>73</string>
     <key>LSRequiresIPhoneOS</key>
     <key>LSRequiresIPhoneOS</key>
     <true />
     <true />
     <key>MGLMapboxMetricsEnabledSettingShownInApp</key>
     <key>MGLMapboxMetricsEnabledSettingShownInApp</key>

+ 23 - 0
mobile/ios/ci_scripts/ci_post_clone.sh

@@ -0,0 +1,23 @@
+#!/bin/sh
+
+# The default execution directory of this script is the ci_scripts directory.
+cd $CI_WORKSPACE/mobile
+
+# Install Flutter using git.
+git clone https://github.com/flutter/flutter.git --depth 1 -b stable $HOME/flutter
+export PATH="$PATH:$HOME/flutter/bin"
+
+# Install Flutter artifacts for iOS (--ios), or macOS (--macos) platforms.
+flutter precache --ios
+
+# Install Flutter dependencies.
+flutter pub get
+
+# Install CocoaPods using Homebrew.
+HOMEBREW_NO_AUTO_UPDATE=1 # disable homebrew's automatic updates.
+brew install cocoapods
+
+# Install CocoaPods dependencies.
+cd ios && pod install # run `pod install` in the `ios` directory.
+
+exit 0

+ 1 - 1
mobile/ios/fastlane/Fastfile

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

+ 6 - 6
mobile/ios/fastlane/report.xml

@@ -5,32 +5,32 @@
     
     
     
     
       
       
-      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000304">
+      <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000198">
         
         
       </testcase>
       </testcase>
     
     
       
       
-      <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.606546">
+      <testcase classname="fastlane.lanes" name="1: increment_version_number" time="1.628003">
         
         
       </testcase>
       </testcase>
     
     
       
       
-      <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.706234">
+      <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="8.590581">
         
         
       </testcase>
       </testcase>
     
     
       
       
-      <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.657686">
+      <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.617507">
         
         
       </testcase>
       </testcase>
     
     
       
       
-      <testcase classname="fastlane.lanes" name="4: build_app" time="68.78265">
+      <testcase classname="fastlane.lanes" name="4: build_app" time="81.79009">
         
         
       </testcase>
       </testcase>
     
     
       
       
-      <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="60.883182">
+      <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="74.632018">
         
         
       </testcase>
       </testcase>
     
     

+ 4 - 0
mobile/lib/constants/hive_box.dart

@@ -4,6 +4,7 @@ const String accessTokenKey = "immichBoxAccessTokenKey"; // Key 1
 const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
 const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
 const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
 const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
 const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
 const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
+const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
 
 
 // Login Info
 // Login Info
 const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
 const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
@@ -29,3 +30,6 @@ const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3
 // Duplicate asset
 // Duplicate asset
 const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
 const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box
 const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1
 const String duplicatedAssetsKey = "immichDuplicatedAssetsKey"; // Key 1
+
+// In app logger
+const String immichLoggerBox = "immichInAppLogger"; // Box

+ 7 - 0
mobile/lib/main.dart

@@ -16,11 +16,13 @@ import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.d
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/tab_navigation_observer.dart';
 import 'package:immich_mobile/routing/tab_navigation_observer.dart';
+import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
 import 'package:immich_mobile/shared/providers/app_state.provider.dart';
 import 'package:immich_mobile/shared/providers/app_state.provider.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/release_info.provider.dart';
 import 'package:immich_mobile/shared/providers/release_info.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
+import 'package:immich_mobile/shared/services/immich_logger.service.dart';
 import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
 import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
 import 'package:immich_mobile/utils/immich_app_theme.dart';
 import 'package:immich_mobile/utils/immich_app_theme.dart';
@@ -31,8 +33,10 @@ void main() async {
   Hive.registerAdapter(HiveSavedLoginInfoAdapter());
   Hive.registerAdapter(HiveSavedLoginInfoAdapter());
   Hive.registerAdapter(HiveBackupAlbumsAdapter());
   Hive.registerAdapter(HiveBackupAlbumsAdapter());
   Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
   Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
+  Hive.registerAdapter(ImmichLoggerMessageAdapter());
 
 
   await Future.wait([
   await Future.wait([
+    Hive.openBox<ImmichLoggerMessage>(immichLoggerBox),
     Hive.openBox(userInfoBox),
     Hive.openBox(userInfoBox),
     Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
     Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
     Hive.openBox(hiveGithubReleaseInfoBox),
     Hive.openBox(hiveGithubReleaseInfoBox),
@@ -58,6 +62,9 @@ void main() async {
     }
     }
   }
   }
 
 
+  // Initialize Immich Logger Service
+  ImmichLogger().init();
+
   runApp(
   runApp(
     EasyLocalization(
     EasyLocalization(
       supportedLocales: locales,
       supportedLocales: locales,

+ 4 - 1
mobile/lib/modules/album/services/album.service.dart

@@ -69,7 +69,10 @@ class AlbumService {
     Iterable<Asset> assets,
     Iterable<Asset> assets,
   ) async {
   ) async {
     return createAlbum(
     return createAlbum(
-        _getNextAlbumName(await getAlbums(isShared: false)), assets, []);
+      _getNextAlbumName(await getAlbums(isShared: false)),
+      assets,
+      [],
+    );
   }
   }
 
 
   Future<AlbumResponseDto?> getAlbumDetail(String albumId) async {
   Future<AlbumResponseDto?> getAlbumDetail(String albumId) async {

+ 35 - 14
mobile/lib/modules/album/ui/album_thumbnail_card.dart

@@ -21,8 +21,39 @@ class AlbumThumbnailCard extends StatelessWidget {
   @override
   @override
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
     var box = Hive.box(userInfoBox);
     var box = Hive.box(userInfoBox);
+    var cardSize = MediaQuery.of(context).size.width / 2 - 18;
+    var isDarkMode = Theme.of(context).brightness == Brightness.dark;
 
 
-    final cardSize = MediaQuery.of(context).size.width / 2 - 18;
+    buildEmptyThumbnail() {
+      return Container(
+        decoration: BoxDecoration(
+          color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
+        ),
+        child: SizedBox(
+          height: cardSize,
+          width: cardSize,
+          child: const Center(
+            child: Icon(Icons.no_photography),
+          ),
+        ),
+      );
+    }
+
+    buildAlbumThumbnail() {
+      return CachedNetworkImage(
+        memCacheHeight: max(400, cardSize.toInt() * 3),
+        width: cardSize,
+        height: cardSize,
+        fit: BoxFit.cover,
+        fadeInDuration: const Duration(milliseconds: 200),
+        imageUrl: getAlbumThumbnailUrl(
+          album,
+          type: ThumbnailFormat.JPEG,
+        ),
+        httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
+        cacheKey: "${album.albumThumbnailAssetId}",
+      );
+    }
 
 
     return GestureDetector(
     return GestureDetector(
       onTap: () {
       onTap: () {
@@ -35,19 +66,9 @@ class AlbumThumbnailCard extends StatelessWidget {
           children: [
           children: [
             ClipRRect(
             ClipRRect(
               borderRadius: BorderRadius.circular(8),
               borderRadius: BorderRadius.circular(8),
-              child: CachedNetworkImage(
-                memCacheHeight: max(400, cardSize.toInt() * 3),
-                width: cardSize,
-                height: cardSize,
-                fit: BoxFit.cover,
-                fadeInDuration: const Duration(milliseconds: 200),
-                imageUrl:
-                    getAlbumThumbnailUrl(album, type: ThumbnailFormat.JPEG),
-                httpHeaders: {
-                  "Authorization": "Bearer ${box.get(accessTokenKey)}"
-                },
-                cacheKey: "${album.albumThumbnailAssetId}",
-              ),
+              child: album.albumThumbnailAssetId == null
+                  ? buildEmptyThumbnail()
+                  : buildAlbumThumbnail(),
             ),
             ),
             Padding(
             Padding(
               padding: const EdgeInsets.only(top: 8.0),
               padding: const EdgeInsets.only(top: 8.0),

+ 12 - 12
mobile/lib/modules/album/ui/album_viewer_appbar.dart

@@ -34,7 +34,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
     final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
     final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
     final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
     final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
 
 
-    void _onDeleteAlbumPressed(String albumId) async {
+    void onDeleteAlbumPressed(String albumId) async {
       ImmichLoadingOverlayController.appLoader.show();
       ImmichLoadingOverlayController.appLoader.show();
 
 
       bool isSuccess =
       bool isSuccess =
@@ -62,7 +62,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
       ImmichLoadingOverlayController.appLoader.hide();
       ImmichLoadingOverlayController.appLoader.hide();
     }
     }
 
 
-    void _onLeaveAlbumPressed(String albumId) async {
+    void onLeaveAlbumPressed(String albumId) async {
       ImmichLoadingOverlayController.appLoader.show();
       ImmichLoadingOverlayController.appLoader.show();
 
 
       bool isSuccess =
       bool isSuccess =
@@ -84,7 +84,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
       ImmichLoadingOverlayController.appLoader.hide();
       ImmichLoadingOverlayController.appLoader.hide();
     }
     }
 
 
-    void _onRemoveFromAlbumPressed(String albumId) async {
+    void onRemoveFromAlbumPressed(String albumId) async {
       ImmichLoadingOverlayController.appLoader.show();
       ImmichLoadingOverlayController.appLoader.show();
 
 
       bool isSuccess =
       bool isSuccess =
@@ -110,7 +110,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
       ImmichLoadingOverlayController.appLoader.hide();
       ImmichLoadingOverlayController.appLoader.hide();
     }
     }
 
 
-    _buildBottomSheetActionButton() {
+    buildBottomSheetActionButton() {
       if (isMultiSelectionEnable) {
       if (isMultiSelectionEnable) {
         if (albumInfo.ownerId == userId) {
         if (albumInfo.ownerId == userId) {
           return ListTile(
           return ListTile(
@@ -119,7 +119,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
               'album_viewer_appbar_share_remove',
               'album_viewer_appbar_share_remove',
               style: TextStyle(fontWeight: FontWeight.bold),
               style: TextStyle(fontWeight: FontWeight.bold),
             ).tr(),
             ).tr(),
-            onTap: () => _onRemoveFromAlbumPressed(albumId),
+            onTap: () => onRemoveFromAlbumPressed(albumId),
           );
           );
         } else {
         } else {
           return const SizedBox();
           return const SizedBox();
@@ -132,7 +132,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
               'album_viewer_appbar_share_delete',
               'album_viewer_appbar_share_delete',
               style: TextStyle(fontWeight: FontWeight.bold),
               style: TextStyle(fontWeight: FontWeight.bold),
             ).tr(),
             ).tr(),
-            onTap: () => _onDeleteAlbumPressed(albumId),
+            onTap: () => onDeleteAlbumPressed(albumId),
           );
           );
         } else {
         } else {
           return ListTile(
           return ListTile(
@@ -141,13 +141,13 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
               'album_viewer_appbar_share_leave',
               'album_viewer_appbar_share_leave',
               style: TextStyle(fontWeight: FontWeight.bold),
               style: TextStyle(fontWeight: FontWeight.bold),
             ).tr(),
             ).tr(),
-            onTap: () => _onLeaveAlbumPressed(albumId),
+            onTap: () => onLeaveAlbumPressed(albumId),
           );
           );
         }
         }
       }
       }
     }
     }
 
 
-    void _buildBottomSheet() {
+    void buildBottomSheet() {
       showModalBottomSheet(
       showModalBottomSheet(
         backgroundColor: Theme.of(context).scaffoldBackgroundColor,
         backgroundColor: Theme.of(context).scaffoldBackgroundColor,
         isScrollControlled: false,
         isScrollControlled: false,
@@ -157,7 +157,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
             child: Column(
             child: Column(
               mainAxisSize: MainAxisSize.min,
               mainAxisSize: MainAxisSize.min,
               children: [
               children: [
-                _buildBottomSheetActionButton(),
+                buildBottomSheetActionButton(),
               ],
               ],
             ),
             ),
           );
           );
@@ -165,7 +165,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
       );
       );
     }
     }
 
 
-    _buildLeadingButton() {
+    buildLeadingButton() {
       if (isMultiSelectionEnable) {
       if (isMultiSelectionEnable) {
         return IconButton(
         return IconButton(
           onPressed: () => ref
           onPressed: () => ref
@@ -204,7 +204,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
 
 
     return AppBar(
     return AppBar(
       elevation: 0,
       elevation: 0,
-      leading: _buildLeadingButton(),
+      leading: buildLeadingButton(),
       title: isMultiSelectionEnable
       title: isMultiSelectionEnable
           ? Text('${selectedAssetsInAlbum.length}')
           ? Text('${selectedAssetsInAlbum.length}')
           : null,
           : null,
@@ -212,7 +212,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
       actions: [
       actions: [
         IconButton(
         IconButton(
           splashRadius: 25,
           splashRadius: 25,
-          onPressed: _buildBottomSheet,
+          onPressed: buildBottomSheet,
           icon: const Icon(Icons.more_horiz_rounded),
           icon: const Icon(Icons.more_horiz_rounded),
         ),
         ),
       ],
       ],

+ 15 - 15
mobile/lib/modules/album/ui/album_viewer_thumbnail.dart

@@ -27,7 +27,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
     final isMultiSelectionEnable =
     final isMultiSelectionEnable =
         ref.watch(assetSelectionProvider).isMultiselectEnable;
         ref.watch(assetSelectionProvider).isMultiselectEnable;
 
 
-    _viewAsset() {
+    viewAsset() {
       AutoRouter.of(context).push(
       AutoRouter.of(context).push(
         GalleryViewerRoute(
         GalleryViewerRoute(
           asset: asset,
           asset: asset,
@@ -47,18 +47,18 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
       }
       }
     }
     }
 
 
-    _enableMultiSelection() {
+    enableMultiSelection() {
       ref.watch(assetSelectionProvider.notifier).enableMultiselection();
       ref.watch(assetSelectionProvider.notifier).enableMultiselection();
       ref
       ref
           .watch(assetSelectionProvider.notifier)
           .watch(assetSelectionProvider.notifier)
           .addAssetsInAlbumViewer([asset]);
           .addAssetsInAlbumViewer([asset]);
     }
     }
 
 
-    _disableMultiSelection() {
+    disableMultiSelection() {
       ref.watch(assetSelectionProvider.notifier).disableMultiselection();
       ref.watch(assetSelectionProvider.notifier).disableMultiselection();
     }
     }
 
 
-    _buildVideoLabel() {
+    buildVideoLabel() {
       return Positioned(
       return Positioned(
         top: 5,
         top: 5,
         right: 5,
         right: 5,
@@ -80,7 +80,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildAssetStoreLocationIcon() {
+    buildAssetStoreLocationIcon() {
       return Positioned(
       return Positioned(
         right: 10,
         right: 10,
         bottom: 5,
         bottom: 5,
@@ -94,7 +94,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildAssetSelectionIcon() {
+    buildAssetSelectionIcon() {
       bool isSelected = selectedAssetsInAlbumViewer.contains(asset);
       bool isSelected = selectedAssetsInAlbumViewer.contains(asset);
 
 
       return Positioned(
       return Positioned(
@@ -112,21 +112,21 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildThumbnailImage() {
+    buildThumbnailImage() {
       return Container(
       return Container(
         decoration: BoxDecoration(border: drawBorderColor()),
         decoration: BoxDecoration(border: drawBorderColor()),
         child: ImmichImage(asset, width: 300, height: 300),
         child: ImmichImage(asset, width: 300, height: 300),
       );
       );
     }
     }
 
 
-    _handleSelectionGesture() {
+    handleSelectionGesture() {
       if (selectedAssetsInAlbumViewer.contains(asset)) {
       if (selectedAssetsInAlbumViewer.contains(asset)) {
         ref
         ref
             .watch(assetSelectionProvider.notifier)
             .watch(assetSelectionProvider.notifier)
             .removeAssetsInAlbumViewer([asset]);
             .removeAssetsInAlbumViewer([asset]);
 
 
         if (selectedAssetsInAlbumViewer.isEmpty) {
         if (selectedAssetsInAlbumViewer.isEmpty) {
-          _disableMultiSelection();
+          disableMultiSelection();
         }
         }
       } else {
       } else {
         ref
         ref
@@ -136,14 +136,14 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
     }
     }
 
 
     return GestureDetector(
     return GestureDetector(
-      onTap: isMultiSelectionEnable ? _handleSelectionGesture : _viewAsset,
-      onLongPress: _enableMultiSelection,
+      onTap: isMultiSelectionEnable ? handleSelectionGesture : viewAsset,
+      onLongPress: enableMultiSelection,
       child: Stack(
       child: Stack(
         children: [
         children: [
-          _buildThumbnailImage(),
-          if (showStorageIndicator) _buildAssetStoreLocationIcon(),
-          if (!asset.isImage) _buildVideoLabel(),
-          if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
+          buildThumbnailImage(),
+          if (showStorageIndicator) buildAssetStoreLocationIcon(),
+          if (!asset.isImage) buildVideoLabel(),
+          if (isMultiSelectionEnable) buildAssetSelectionIcon(),
         ],
         ],
       ),
       ),
     );
     );

+ 5 - 5
mobile/lib/modules/album/ui/month_group_title.dart

@@ -21,7 +21,7 @@ class MonthGroupTitle extends HookConsumerWidget {
         ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
         ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
     final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
     final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
 
 
-    _handleTitleIconClick() {
+    handleTitleIconClick() {
       HapticFeedback.heavyImpact();
       HapticFeedback.heavyImpact();
 
 
       if (isAlbumExist) {
       if (isAlbumExist) {
@@ -61,7 +61,7 @@ class MonthGroupTitle extends HookConsumerWidget {
       }
       }
     }
     }
 
 
-    _getSimplifiedMonth() {
+    getSimplifiedMonth() {
       var monthAndYear = month.split(',');
       var monthAndYear = month.split(',');
       var yearText = monthAndYear[1].trim();
       var yearText = monthAndYear[1].trim();
       var monthText = monthAndYear[0].trim();
       var monthText = monthAndYear[0].trim();
@@ -85,7 +85,7 @@ class MonthGroupTitle extends HookConsumerWidget {
         child: Row(
         child: Row(
           children: [
           children: [
             GestureDetector(
             GestureDetector(
-              onTap: _handleTitleIconClick,
+              onTap: handleTitleIconClick,
               child: selectedDateGroup.contains(month)
               child: selectedDateGroup.contains(month)
                   ? Icon(
                   ? Icon(
                       Icons.check_circle_rounded,
                       Icons.check_circle_rounded,
@@ -97,11 +97,11 @@ class MonthGroupTitle extends HookConsumerWidget {
                     ),
                     ),
             ),
             ),
             GestureDetector(
             GestureDetector(
-              onTap: _handleTitleIconClick,
+              onTap: handleTitleIconClick,
               child: Padding(
               child: Padding(
                 padding: const EdgeInsets.only(left: 8.0),
                 padding: const EdgeInsets.only(left: 8.0),
                 child: Text(
                 child: Text(
-                  _getSimplifiedMonth(),
+                  getSimplifiedMonth(),
                   style: TextStyle(
                   style: TextStyle(
                     fontSize: 24,
                     fontSize: 24,
                     color: Theme.of(context).primaryColor,
                     color: Theme.of(context).primaryColor,

+ 2 - 2
mobile/lib/modules/album/ui/selection_thumbnail_image.dart

@@ -18,7 +18,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
         ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
         ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
     var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
     var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
 
 
-    Widget _buildSelectionIcon(Asset asset) {
+    Widget buildSelectionIcon(Asset asset) {
       var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
       var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
       var isNewlySelected =
       var isNewlySelected =
           newAssetsForAlbum.map((item) => item.id).contains(asset.id);
           newAssetsForAlbum.map((item) => item.id).contains(asset.id);
@@ -111,7 +111,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
             padding: const EdgeInsets.all(3.0),
             padding: const EdgeInsets.all(3.0),
             child: Align(
             child: Align(
               alignment: Alignment.topLeft,
               alignment: Alignment.topLeft,
-              child: _buildSelectionIcon(asset),
+              child: buildSelectionIcon(asset),
             ),
             ),
           ),
           ),
           if (!asset.isImage)
           if (!asset.isImage)

+ 18 - 17
mobile/lib/modules/album/views/album_viewer_page.dart

@@ -37,7 +37,7 @@ class AlbumViewerPage extends HookConsumerWidget {
 
 
     /// Find out if the assets in album exist on the device
     /// Find out if the assets in album exist on the device
     /// If they exist, add to selected asset state to show they are already selected.
     /// If they exist, add to selected asset state to show they are already selected.
-    void _onAddPhotosPressed(AlbumResponseDto albumInfo) async {
+    void onAddPhotosPressed(AlbumResponseDto albumInfo) async {
       if (albumInfo.assets.isNotEmpty == true) {
       if (albumInfo.assets.isNotEmpty == true) {
         ref.watch(assetSelectionProvider.notifier).addNewAssets(
         ref.watch(assetSelectionProvider.notifier).addNewAssets(
               albumInfo.assets.map((e) => Asset.remote(e)).toList(),
               albumInfo.assets.map((e) => Asset.remote(e)).toList(),
@@ -60,7 +60,8 @@ class AlbumViewerPage extends HookConsumerWidget {
                     albumId,
                     albumId,
                   );
                   );
 
 
-          if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) {
+          if (addAssetsResult != null &&
+              addAssetsResult.successfullyAdded > 0) {
             ref.refresh(sharedAlbumDetailProvider(albumId));
             ref.refresh(sharedAlbumDetailProvider(albumId));
           }
           }
 
 
@@ -73,7 +74,7 @@ class AlbumViewerPage extends HookConsumerWidget {
       }
       }
     }
     }
 
 
-    void _onAddUsersPressed(AlbumResponseDto albumInfo) async {
+    void onAddUsersPressed(AlbumResponseDto albumInfo) async {
       List<String>? sharedUserIds =
       List<String>? sharedUserIds =
           await AutoRouter.of(context).push<List<String>?>(
           await AutoRouter.of(context).push<List<String>?>(
         SelectAdditionalUserForSharingRoute(albumInfo: albumInfo),
         SelectAdditionalUserForSharingRoute(albumInfo: albumInfo),
@@ -94,7 +95,7 @@ class AlbumViewerPage extends HookConsumerWidget {
       }
       }
     }
     }
 
 
-    Widget _buildTitle(AlbumResponseDto albumInfo) {
+    Widget buildTitle(AlbumResponseDto albumInfo) {
       return Padding(
       return Padding(
         padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
         padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
         child: userId == albumInfo.ownerId
         child: userId == albumInfo.ownerId
@@ -115,7 +116,7 @@ class AlbumViewerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    Widget _buildAlbumDateRange(AlbumResponseDto albumInfo) {
+    Widget buildAlbumDateRange(AlbumResponseDto albumInfo) {
       String startDate = "";
       String startDate = "";
       DateTime parsedStartDate =
       DateTime parsedStartDate =
           DateTime.parse(albumInfo.assets.first.createdAt);
           DateTime.parse(albumInfo.assets.first.createdAt);
@@ -148,14 +149,14 @@ class AlbumViewerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    Widget _buildHeader(AlbumResponseDto albumInfo) {
+    Widget buildHeader(AlbumResponseDto albumInfo) {
       return SliverToBoxAdapter(
       return SliverToBoxAdapter(
         child: Column(
         child: Column(
           crossAxisAlignment: CrossAxisAlignment.start,
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
           children: [
-            _buildTitle(albumInfo),
+            buildTitle(albumInfo),
             if (albumInfo.assets.isNotEmpty == true)
             if (albumInfo.assets.isNotEmpty == true)
-              _buildAlbumDateRange(albumInfo),
+              buildAlbumDateRange(albumInfo),
             if (albumInfo.shared)
             if (albumInfo.shared)
               SizedBox(
               SizedBox(
                 height: 60,
                 height: 60,
@@ -188,7 +189,7 @@ class AlbumViewerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    Widget _buildImageGrid(AlbumResponseDto albumInfo) {
+    Widget buildImageGrid(AlbumResponseDto albumInfo) {
       final appSettingService = ref.watch(appSettingsServiceProvider);
       final appSettingService = ref.watch(appSettingsServiceProvider);
       final bool showStorageIndicator =
       final bool showStorageIndicator =
           appSettingService.getSetting(AppSettingsEnum.storageIndicator);
           appSettingService.getSetting(AppSettingsEnum.storageIndicator);
@@ -220,7 +221,7 @@ class AlbumViewerPage extends HookConsumerWidget {
       return const SliverToBoxAdapter();
       return const SliverToBoxAdapter();
     }
     }
 
 
-    Widget _buildControlButton(AlbumResponseDto albumInfo) {
+    Widget buildControlButton(AlbumResponseDto albumInfo) {
       return Padding(
       return Padding(
         padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
         padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8),
         child: SizedBox(
         child: SizedBox(
@@ -230,13 +231,13 @@ class AlbumViewerPage extends HookConsumerWidget {
             children: [
             children: [
               AlbumActionOutlinedButton(
               AlbumActionOutlinedButton(
                 iconData: Icons.add_photo_alternate_outlined,
                 iconData: Icons.add_photo_alternate_outlined,
-                onPressed: () => _onAddPhotosPressed(albumInfo),
+                onPressed: () => onAddPhotosPressed(albumInfo),
                 labelText: "share_add_photos".tr(),
                 labelText: "share_add_photos".tr(),
               ),
               ),
               if (userId == albumInfo.ownerId)
               if (userId == albumInfo.ownerId)
                 AlbumActionOutlinedButton(
                 AlbumActionOutlinedButton(
                   iconData: Icons.person_add_alt_rounded,
                   iconData: Icons.person_add_alt_rounded,
-                  onPressed: () => _onAddUsersPressed(albumInfo),
+                  onPressed: () => onAddUsersPressed(albumInfo),
                   labelText: "album_viewer_page_share_add_users".tr(),
                   labelText: "album_viewer_page_share_add_users".tr(),
                 ),
                 ),
             ],
             ],
@@ -245,7 +246,7 @@ class AlbumViewerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    Widget _buildBody(AlbumResponseDto albumInfo) {
+    Widget buildBody(AlbumResponseDto albumInfo) {
       return GestureDetector(
       return GestureDetector(
         onTap: () {
         onTap: () {
           titleFocusNode.unfocus();
           titleFocusNode.unfocus();
@@ -257,7 +258,7 @@ class AlbumViewerPage extends HookConsumerWidget {
           child: CustomScrollView(
           child: CustomScrollView(
             controller: scrollController,
             controller: scrollController,
             slivers: [
             slivers: [
-              _buildHeader(albumInfo),
+              buildHeader(albumInfo),
               SliverPersistentHeader(
               SliverPersistentHeader(
                 pinned: true,
                 pinned: true,
                 delegate: ImmichSliverPersistentAppBarDelegate(
                 delegate: ImmichSliverPersistentAppBarDelegate(
@@ -265,11 +266,11 @@ class AlbumViewerPage extends HookConsumerWidget {
                   maxHeight: 50,
                   maxHeight: 50,
                   child: Container(
                   child: Container(
                     color: Theme.of(context).scaffoldBackgroundColor,
                     color: Theme.of(context).scaffoldBackgroundColor,
-                    child: _buildControlButton(albumInfo),
+                    child: buildControlButton(albumInfo),
                   ),
                   ),
                 ),
                 ),
               ),
               ),
-              _buildImageGrid(albumInfo)
+              buildImageGrid(albumInfo)
             ],
             ],
           ),
           ),
         ),
         ),
@@ -293,7 +294,7 @@ class AlbumViewerPage extends HookConsumerWidget {
       ),
       ),
       body: albumInfo.when(
       body: albumInfo.when(
         data: (albumInfo) => albumInfo != null
         data: (albumInfo) => albumInfo != null
-            ? _buildBody(albumInfo)
+            ? buildBody(albumInfo)
             : const Center(
             : const Center(
                 child: CircularProgressIndicator(),
                 child: CircularProgressIndicator(),
               ),
               ),

+ 4 - 4
mobile/lib/modules/album/views/asset_selection_page.dart

@@ -25,7 +25,7 @@ class AssetSelectionPage extends HookConsumerWidget {
 
 
     List<Widget> imageGridGroup = [];
     List<Widget> imageGridGroup = [];
 
 
-    String _buildAssetCountText() {
+    String buildAssetCountText() {
       if (isAlbumExist) {
       if (isAlbumExist) {
         return (selectedAssets.length + newAssetsForAlbum.length).toString();
         return (selectedAssets.length + newAssetsForAlbum.length).toString();
       } else {
       } else {
@@ -33,7 +33,7 @@ class AssetSelectionPage extends HookConsumerWidget {
       }
       }
     }
     }
 
 
-    Widget _buildBody() {
+    Widget buildBody() {
       assetGroupMonthYear.forEach((monthYear, assetGroup) {
       assetGroupMonthYear.forEach((monthYear, assetGroup) {
         imageGridGroup
         imageGridGroup
             .add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup));
             .add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup));
@@ -71,7 +71,7 @@ class AssetSelectionPage extends HookConsumerWidget {
                 style: TextStyle(fontSize: 18),
                 style: TextStyle(fontSize: 18),
               ).tr()
               ).tr()
             : Text(
             : Text(
-                _buildAssetCountText(),
+                buildAssetCountText(),
                 style: const TextStyle(fontSize: 18),
                 style: const TextStyle(fontSize: 18),
               ),
               ),
         centerTitle: false,
         centerTitle: false,
@@ -94,7 +94,7 @@ class AssetSelectionPage extends HookConsumerWidget {
             ),
             ),
         ],
         ],
       ),
       ),
-      body: _buildBody(),
+      body: buildBody(),
     );
     );
   }
   }
 }
 }

+ 20 - 20
mobile/lib/modules/album/views/create_album_page.dart

@@ -29,11 +29,11 @@ class CreateAlbumPage extends HookConsumerWidget {
         ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
         ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
     final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
     final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
 
 
-    _showSelectUserPage() {
+    showSelectUserPage() {
       AutoRouter.of(context).push(const SelectUserForSharingRoute());
       AutoRouter.of(context).push(const SelectUserForSharingRoute());
     }
     }
 
 
-    void _onBackgroundTapped() {
+    void onBackgroundTapped() {
       albumTitleTextFieldFocusNode.unfocus();
       albumTitleTextFieldFocusNode.unfocus();
       isAlbumTitleTextFieldFocus.value = false;
       isAlbumTitleTextFieldFocus.value = false;
 
 
@@ -45,7 +45,7 @@ class CreateAlbumPage extends HookConsumerWidget {
       }
       }
     }
     }
 
 
-    _onSelectPhotosButtonPressed() async {
+    onSelectPhotosButtonPressed() async {
       ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false);
       ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false);
 
 
       AssetSelectionPageResult? selectedAsset = await AutoRouter.of(context)
       AssetSelectionPageResult? selectedAsset = await AutoRouter.of(context)
@@ -56,7 +56,7 @@ class CreateAlbumPage extends HookConsumerWidget {
       }
       }
     }
     }
 
 
-    _buildTitleInputField() {
+    buildTitleInputField() {
       return Padding(
       return Padding(
         padding: const EdgeInsets.only(
         padding: const EdgeInsets.only(
           right: 10,
           right: 10,
@@ -71,7 +71,7 @@ class CreateAlbumPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildTitle() {
+    buildTitle() {
       if (selectedAssets.isEmpty) {
       if (selectedAssets.isEmpty) {
         return SliverToBoxAdapter(
         return SliverToBoxAdapter(
           child: Padding(
           child: Padding(
@@ -90,7 +90,7 @@ class CreateAlbumPage extends HookConsumerWidget {
       return const SliverToBoxAdapter();
       return const SliverToBoxAdapter();
     }
     }
 
 
-    _buildSelectPhotosButton() {
+    buildSelectPhotosButton() {
       if (selectedAssets.isEmpty) {
       if (selectedAssets.isEmpty) {
         return SliverToBoxAdapter(
         return SliverToBoxAdapter(
           child: Padding(
           child: Padding(
@@ -109,7 +109,7 @@ class CreateAlbumPage extends HookConsumerWidget {
                   borderRadius: BorderRadius.circular(5),
                   borderRadius: BorderRadius.circular(5),
                 ),
                 ),
               ),
               ),
-              onPressed: _onSelectPhotosButtonPressed,
+              onPressed: onSelectPhotosButtonPressed,
               icon: Icon(
               icon: Icon(
                 Icons.add_rounded,
                 Icons.add_rounded,
                 color: Theme.of(context).primaryColor,
                 color: Theme.of(context).primaryColor,
@@ -132,7 +132,7 @@ class CreateAlbumPage extends HookConsumerWidget {
       return const SliverToBoxAdapter();
       return const SliverToBoxAdapter();
     }
     }
 
 
-    _buildControlButton() {
+    buildControlButton() {
       return Padding(
       return Padding(
         padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16),
         padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16),
         child: SizedBox(
         child: SizedBox(
@@ -142,7 +142,7 @@ class CreateAlbumPage extends HookConsumerWidget {
             children: [
             children: [
               AlbumActionOutlinedButton(
               AlbumActionOutlinedButton(
                 iconData: Icons.add_photo_alternate_outlined,
                 iconData: Icons.add_photo_alternate_outlined,
-                onPressed: _onSelectPhotosButtonPressed,
+                onPressed: onSelectPhotosButtonPressed,
                 labelText: "share_add_photos".tr(),
                 labelText: "share_add_photos".tr(),
               ),
               ),
             ],
             ],
@@ -151,7 +151,7 @@ class CreateAlbumPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildSelectedImageGrid() {
+    buildSelectedImageGrid() {
       if (selectedAssets.isNotEmpty) {
       if (selectedAssets.isNotEmpty) {
         return SliverPadding(
         return SliverPadding(
           padding: const EdgeInsets.only(top: 16),
           padding: const EdgeInsets.only(top: 16),
@@ -164,7 +164,7 @@ class CreateAlbumPage extends HookConsumerWidget {
             delegate: SliverChildBuilderDelegate(
             delegate: SliverChildBuilderDelegate(
               (BuildContext context, int index) {
               (BuildContext context, int index) {
                 return GestureDetector(
                 return GestureDetector(
-                  onTap: _onBackgroundTapped,
+                  onTap: onBackgroundTapped,
                   child: SharedAlbumThumbnailImage(
                   child: SharedAlbumThumbnailImage(
                     asset: selectedAssets.elementAt(index),
                     asset: selectedAssets.elementAt(index),
                   ),
                   ),
@@ -179,7 +179,7 @@ class CreateAlbumPage extends HookConsumerWidget {
       return const SliverToBoxAdapter();
       return const SliverToBoxAdapter();
     }
     }
 
 
-    _createNonSharedAlbum() async {
+    createNonSharedAlbum() async {
       var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
       var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(
             ref.watch(albumTitleProvider),
             ref.watch(albumTitleProvider),
             ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum,
             ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum,
@@ -216,7 +216,7 @@ class CreateAlbumPage extends HookConsumerWidget {
           if (isSharedAlbum)
           if (isSharedAlbum)
             TextButton(
             TextButton(
               onPressed: albumTitleController.text.isNotEmpty
               onPressed: albumTitleController.text.isNotEmpty
-                  ? _showSelectUserPage
+                  ? showSelectUserPage
                   : null,
                   : null,
               child: Text(
               child: Text(
                 'create_shared_album_page_share'.tr(),
                 'create_shared_album_page_share'.tr(),
@@ -230,7 +230,7 @@ class CreateAlbumPage extends HookConsumerWidget {
             TextButton(
             TextButton(
               onPressed: albumTitleController.text.isNotEmpty &&
               onPressed: albumTitleController.text.isNotEmpty &&
                       selectedAssets.isNotEmpty
                       selectedAssets.isNotEmpty
-                  ? _createNonSharedAlbum
+                  ? createNonSharedAlbum
                   : null,
                   : null,
               child: Text(
               child: Text(
                 'create_shared_album_page_create'.tr(),
                 'create_shared_album_page_create'.tr(),
@@ -242,7 +242,7 @@ class CreateAlbumPage extends HookConsumerWidget {
         ],
         ],
       ),
       ),
       body: GestureDetector(
       body: GestureDetector(
-        onTap: _onBackgroundTapped,
+        onTap: onBackgroundTapped,
         child: CustomScrollView(
         child: CustomScrollView(
           slivers: [
           slivers: [
             SliverAppBar(
             SliverAppBar(
@@ -255,15 +255,15 @@ class CreateAlbumPage extends HookConsumerWidget {
                 preferredSize: const Size.fromHeight(66.0),
                 preferredSize: const Size.fromHeight(66.0),
                 child: Column(
                 child: Column(
                   children: [
                   children: [
-                    _buildTitleInputField(),
-                    if (selectedAssets.isNotEmpty) _buildControlButton(),
+                    buildTitleInputField(),
+                    if (selectedAssets.isNotEmpty) buildControlButton(),
                   ],
                   ],
                 ),
                 ),
               ),
               ),
             ),
             ),
-            _buildTitle(),
-            _buildSelectPhotosButton(),
-            _buildSelectedImageGrid(),
+            buildTitle(),
+            buildSelectPhotosButton(),
+            buildSelectedImageGrid(),
           ],
           ],
         ),
         ),
       ),
       ),

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

@@ -19,12 +19,12 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
         ref.watch(suggestedSharedUsersProvider);
         ref.watch(suggestedSharedUsersProvider);
     final sharedUsersList = useState<Set<UserResponseDto>>({});
     final sharedUsersList = useState<Set<UserResponseDto>>({});
 
 
-    _addNewUsersHandler() {
+    addNewUsersHandler() {
       AutoRouter.of(context)
       AutoRouter.of(context)
           .pop(sharedUsersList.value.map((e) => e.id).toList());
           .pop(sharedUsersList.value.map((e) => e.id).toList());
     }
     }
 
 
-    _buildTileIcon(UserResponseDto user) {
+    buildTileIcon(UserResponseDto user) {
       if (sharedUsersList.value.contains(user)) {
       if (sharedUsersList.value.contains(user)) {
         return CircleAvatar(
         return CircleAvatar(
           backgroundColor: Theme.of(context).primaryColor,
           backgroundColor: Theme.of(context).primaryColor,
@@ -42,7 +42,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
       }
       }
     }
     }
 
 
-    _buildUserList(List<UserResponseDto> users) {
+    buildUserList(List<UserResponseDto> users) {
       List<Widget> usersChip = [];
       List<Widget> usersChip = [];
 
 
       for (var user in sharedUsersList.value) {
       for (var user in sharedUsersList.value) {
@@ -84,7 +84,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
             shrinkWrap: true,
             shrinkWrap: true,
             itemBuilder: ((context, index) {
             itemBuilder: ((context, index) {
               return ListTile(
               return ListTile(
-                leading: _buildTileIcon(users[index]),
+                leading: buildTileIcon(users[index]),
                 title: Text(
                 title: Text(
                   users[index].email,
                   users[index].email,
                   style: const TextStyle(
                   style: const TextStyle(
@@ -131,7 +131,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
         actions: [
         actions: [
           TextButton(
           TextButton(
             onPressed:
             onPressed:
-                sharedUsersList.value.isEmpty ? null : _addNewUsersHandler,
+                sharedUsersList.value.isEmpty ? null : addNewUsersHandler,
             child: const Text(
             child: const Text(
               "share_add",
               "share_add",
               style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
               style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
@@ -147,7 +147,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
             );
             );
           }
           }
 
 
-          return _buildUserList(users);
+          return buildUserList(users);
         },
         },
         error: (e, _) => Text("Error loading suggested users $e"),
         error: (e, _) => Text("Error loading suggested users $e"),
         loading: () => const Center(
         loading: () => const Center(

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

@@ -20,7 +20,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
     AsyncValue<List<UserResponseDto>> suggestedShareUsers =
     AsyncValue<List<UserResponseDto>> suggestedShareUsers =
         ref.watch(suggestedSharedUsersProvider);
         ref.watch(suggestedSharedUsersProvider);
 
 
-    _createSharedAlbum() async {
+    createSharedAlbum() async {
       var newAlbum =
       var newAlbum =
           await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum(
           await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum(
                 ref.watch(albumTitleProvider),
                 ref.watch(albumTitleProvider),
@@ -44,7 +44,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildTileIcon(UserResponseDto user) {
+    buildTileIcon(UserResponseDto user) {
       if (sharedUsersList.value.contains(user)) {
       if (sharedUsersList.value.contains(user)) {
         return CircleAvatar(
         return CircleAvatar(
           backgroundColor: Theme.of(context).primaryColor,
           backgroundColor: Theme.of(context).primaryColor,
@@ -62,7 +62,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
       }
       }
     }
     }
 
 
-    _buildUserList(List<UserResponseDto> users) {
+    buildUserList(List<UserResponseDto> users) {
       List<Widget> usersChip = [];
       List<Widget> usersChip = [];
 
 
       for (var user in sharedUsersList.value) {
       for (var user in sharedUsersList.value) {
@@ -104,7 +104,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
             shrinkWrap: true,
             shrinkWrap: true,
             itemBuilder: ((context, index) {
             itemBuilder: ((context, index) {
               return ListTile(
               return ListTile(
-                leading: _buildTileIcon(users[index]),
+                leading: buildTileIcon(users[index]),
                 title: Text(
                 title: Text(
                   users[index].email,
                   users[index].email,
                   style: const TextStyle(
                   style: const TextStyle(
@@ -153,8 +153,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
             style: TextButton.styleFrom(
             style: TextButton.styleFrom(
               foregroundColor: Theme.of(context).primaryColor,
               foregroundColor: Theme.of(context).primaryColor,
             ),
             ),
-            onPressed:
-                sharedUsersList.value.isEmpty ? null : _createSharedAlbum,
+            onPressed: sharedUsersList.value.isEmpty ? null : createSharedAlbum,
             child: const Text(
             child: const Text(
               "share_create_album",
               "share_create_album",
               style: TextStyle(
               style: TextStyle(
@@ -168,7 +167,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
       ),
       ),
       body: suggestedShareUsers.when(
       body: suggestedShareUsers.when(
         data: (users) {
         data: (users) {
-          return _buildUserList(users);
+          return buildUserList(users);
         },
         },
         error: (e, _) => Text("Error loading suggested users $e"),
         error: (e, _) => Text("Error loading suggested users $e"),
         loading: () => const Center(
         loading: () => const Center(

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

@@ -28,7 +28,7 @@ class SharingPage extends HookConsumerWidget {
       [],
       [],
     );
     );
 
 
-    _buildAlbumList() {
+    buildAlbumList() {
       return SliverList(
       return SliverList(
         delegate: SliverChildBuilderDelegate(
         delegate: SliverChildBuilderDelegate(
           (BuildContext context, int index) {
           (BuildContext context, int index) {
@@ -71,7 +71,7 @@ class SharingPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildEmptyListIndication() {
+    buildEmptyListIndication() {
       return SliverToBoxAdapter(
       return SliverToBoxAdapter(
         child: Padding(
         child: Padding(
           padding: const EdgeInsets.all(8.0),
           padding: const EdgeInsets.all(8.0),
@@ -136,8 +136,8 @@ class SharingPage extends HookConsumerWidget {
             ),
             ),
           ),
           ),
           sharedAlbums.isNotEmpty
           sharedAlbums.isNotEmpty
-              ? _buildAlbumList()
-              : _buildEmptyListIndication()
+              ? buildAlbumList()
+              : buildEmptyListIndication()
         ],
         ],
       ),
       ),
     );
     );

+ 3 - 1
mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart

@@ -64,5 +64,7 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
 final imageViewerStateProvider =
 final imageViewerStateProvider =
     StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(
     StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(
   ((ref) => ImageViewerStateNotifier(
   ((ref) => ImageViewerStateNotifier(
-      ref.watch(imageViewerServiceProvider), ref.watch(shareServiceProvider))),
+        ref.watch(imageViewerServiceProvider),
+        ref.watch(shareServiceProvider),
+      )),
 );
 );

+ 7 - 6
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart

@@ -6,6 +6,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:path/path.dart' as p;
 import 'package:path/path.dart' as p;
 import 'package:latlong2/latlong.dart';
 import 'package:latlong2/latlong.dart';
+import 'package:immich_mobile/utils/bytes_units.dart';
 
 
 class ExifBottomSheet extends ConsumerWidget {
 class ExifBottomSheet extends ConsumerWidget {
   final Asset assetDetail;
   final Asset assetDetail;
@@ -15,7 +16,7 @@ class ExifBottomSheet extends ConsumerWidget {
 
 
   @override
   @override
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
-    _buildMap() {
+    buildMap() {
       return Padding(
       return Padding(
         padding: const EdgeInsets.symmetric(vertical: 16.0),
         padding: const EdgeInsets.symmetric(vertical: 16.0),
         child: Container(
         child: Container(
@@ -66,7 +67,7 @@ class ExifBottomSheet extends ConsumerWidget {
 
 
     ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo;
     ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo;
 
 
-    _buildLocationText() {
+    buildLocationText() {
       return Text(
       return Text(
         "${exifInfo?.city}, ${exifInfo?.state}",
         "${exifInfo?.city}, ${exifInfo?.state}",
         style: TextStyle(
         style: TextStyle(
@@ -120,11 +121,11 @@ class ExifBottomSheet extends ConsumerWidget {
                   ).tr(),
                   ).tr(),
                   if (assetDetail.latitude != null &&
                   if (assetDetail.latitude != null &&
                       assetDetail.longitude != null)
                       assetDetail.longitude != null)
-                    _buildMap(),
+                    buildMap(),
                   if (exifInfo != null &&
                   if (exifInfo != null &&
                       exifInfo.city != null &&
                       exifInfo.city != null &&
                       exifInfo.state != null)
                       exifInfo.state != null)
-                    _buildLocationText(),
+                    buildLocationText(),
                   Text(
                   Text(
                     "${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}",
                     "${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}",
                     style: TextStyle(fontSize: 12, color: Colors.grey[400]),
                     style: TextStyle(fontSize: 12, color: Colors.grey[400]),
@@ -162,7 +163,7 @@ class ExifBottomSheet extends ConsumerWidget {
                     ),
                     ),
                     subtitle: exifInfo.exifImageHeight != null
                     subtitle: exifInfo.exifImageHeight != null
                         ? Text(
                         ? Text(
-                            "${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth}  ${exifInfo.fileSizeInByte!}B ",
+                            "${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth}  ${formatBytes(exifInfo.fileSizeInByte!)} ",
                           )
                           )
                         : null,
                         : null,
                   ),
                   ),
@@ -178,7 +179,7 @@ class ExifBottomSheet extends ConsumerWidget {
                         style: const TextStyle(fontWeight: FontWeight.bold),
                         style: const TextStyle(fontWeight: FontWeight.bold),
                       ),
                       ),
                       subtitle: Text(
                       subtitle: Text(
-                        "ƒ/${exifInfo.fNumber}   1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)}   ${exifInfo.focalLength}mm   ISO${exifInfo.iso} ",
+                        "ƒ/${exifInfo.fNumber}   1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)}   ${exifInfo.focalLength} mm   ISO${exifInfo.iso} ",
                       ),
                       ),
                     ),
                     ),
                 ],
                 ],

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

@@ -349,7 +349,6 @@ class BackgroundService {
       Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
       Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
       Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
       Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
     ]);
     ]);
-
     ApiService apiService = ApiService();
     ApiService apiService = ApiService();
     apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
     apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
     apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
     apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));

+ 2 - 0
mobile/lib/modules/backup/background_service/localization.dart

@@ -1,3 +1,5 @@
+// ignore_for_file: implementation_imports
+
 import 'package:flutter/foundation.dart';
 import 'package:flutter/foundation.dart';
 import 'package:easy_localization/src/asset_loader.dart';
 import 'package:easy_localization/src/asset_loader.dart';
 import 'package:easy_localization/src/easy_localization_controller.dart';
 import 'package:easy_localization/src/easy_localization_controller.dart';

+ 45 - 27
mobile/lib/modules/backup/providers/backup.provider.dart

@@ -1,7 +1,7 @@
 import 'dart:io';
 import 'dart:io';
 
 
 import 'package:cancellation_token_http/http.dart';
 import 'package:cancellation_token_http/http.dart';
-import 'package:flutter/foundation.dart';
+import 'package:flutter/widgets.dart';
 import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
@@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/login/models/authentication_state.model.da
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/shared/providers/app_state.provider.dart';
 import 'package:immich_mobile/shared/providers/app_state.provider.dart';
 import 'package:immich_mobile/shared/services/server_info.service.dart';
 import 'package:immich_mobile/shared/services/server_info.service.dart';
+import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:photo_manager/photo_manager.dart';
 
 
@@ -62,6 +63,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     getBackupInfo();
     getBackupInfo();
   }
   }
 
 
+  final log = Logger('BackupNotifier');
   final BackupService _backupService;
   final BackupService _backupService;
   final ServerInfoService _serverInfoService;
   final ServerInfoService _serverInfoService;
   final AuthenticationState _authState;
   final AuthenticationState _authState;
@@ -171,9 +173,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
   /// Get all album on the device
   /// Get all album on the device
   /// Get all selected and excluded album from the user's persistent storage
   /// Get all selected and excluded album from the user's persistent storage
   /// If this is the first time performing backup - set the default selected album to be
   /// If this is the first time performing backup - set the default selected album to be
-  /// the one that has all assets (Recent on Android, Recents on iOS)
+  /// the one that has all assets (`Recent` on Android, `Recents` on iOS)
   ///
   ///
   Future<void> _getBackupAlbumsInfo() async {
   Future<void> _getBackupAlbumsInfo() async {
+    Stopwatch stopwatch = Stopwatch()..start();
     // Get all albums on the device
     // Get all albums on the device
     List<AvailableAlbum> availableAlbums = [];
     List<AvailableAlbum> availableAlbums = [];
     List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
     List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
@@ -181,6 +184,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
       type: RequestType.common,
       type: RequestType.common,
     );
     );
 
 
+    log.info('Found ${albums.length} local albums');
+
     for (AssetPathEntity album in albums) {
     for (AssetPathEntity album in albums) {
       AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
       AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
 
 
@@ -218,13 +223,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     );
     );
 
 
     if (backupAlbumInfo == null) {
     if (backupAlbumInfo == null) {
-      debugPrint("[ERROR] getting Hive backup album infomation");
+      log.severe(
+        "backupAlbumInfo == null",
+        "Failed to get Hive backup album information",
+      );
       return;
       return;
     }
     }
 
 
     // First time backup - set isAll album is the default one for backup.
     // First time backup - set isAll album is the default one for backup.
     if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
     if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
-      debugPrint("First time backup setup recent album as default");
+      log.info("First time backup; setup 'Recent(s)' album as default");
 
 
       // Get album that contains all assets
       // Get album that contains all assets
       var list = await PhotoManager.getAssetPathList(
       var list = await PhotoManager.getAssetPathList(
@@ -286,9 +294,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
         selectedBackupAlbums: selectedAlbums,
         selectedBackupAlbums: selectedAlbums,
         excludedBackupAlbums: excludedAlbums,
         excludedBackupAlbums: excludedAlbums,
       );
       );
-    } catch (e) {
-      debugPrint("[ERROR] Failed to generate album from id $e");
+    } catch (e, stackTrace) {
+      log.severe("Failed to generate album from id", e, stackTrace);
     }
     }
+
+    debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
   }
   }
 
 
   ///
   ///
@@ -338,7 +348,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     );
     );
 
 
     if (allUniqueAssets.isEmpty) {
     if (allUniqueAssets.isEmpty) {
-      debugPrint("No Asset On Device");
+      log.info("Not found albums or assets on the device to backup");
       state = state.copyWith(
       state = state.copyWith(
         backupProgress: BackUpProgressEnum.idle,
         backupProgress: BackUpProgressEnum.idle,
         allAssetsInDatabase: allAssetsInDatabase,
         allAssetsInDatabase: allAssetsInDatabase,
@@ -360,14 +370,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     return;
     return;
   }
   }
 
 
-  ///
   /// Get all necessary information for calculating the available albums,
   /// Get all necessary information for calculating the available albums,
   /// which albums are selected or excluded
   /// which albums are selected or excluded
   /// and then update the UI according to those information
   /// and then update the UI according to those information
-  ///
   Future<void> getBackupInfo() async {
   Future<void> getBackupInfo() async {
-    final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
+    var isEnabled = await _backgroundService.isBackgroundBackupEnabled();
+
     state = state.copyWith(backgroundBackup: isEnabled);
     state = state.copyWith(backgroundBackup: isEnabled);
+
     if (state.backupProgress != BackUpProgressEnum.inBackground) {
     if (state.backupProgress != BackUpProgressEnum.inBackground) {
       await _getBackupAlbumsInfo();
       await _getBackupAlbumsInfo();
       await _updateServerInfo();
       await _updateServerInfo();
@@ -375,10 +385,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     }
     }
   }
   }
 
 
-  ///
   /// Save user selection of selected albums and excluded albums to
   /// Save user selection of selected albums and excluded albums to
   /// Hive database
   /// Hive database
-  ///
   void _updatePersistentAlbumsSelection() {
   void _updatePersistentAlbumsSelection() {
     final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
     final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
     Box<HiveBackupAlbums> backupAlbumInfoBox =
     Box<HiveBackupAlbums> backupAlbumInfoBox =
@@ -398,10 +406,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
     );
     );
   }
   }
 
 
-  ///
   /// Invoke backup process
   /// Invoke backup process
-  ///
   Future<void> startBackupProcess() async {
   Future<void> startBackupProcess() async {
+    debugPrint("Start backup process");
     assert(state.backupProgress == BackUpProgressEnum.idle);
     assert(state.backupProgress == BackUpProgressEnum.idle);
     state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
     state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
 
 
@@ -412,13 +419,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
       await PhotoManager.clearFileCache();
       await PhotoManager.clearFileCache();
 
 
       if (state.allUniqueAssets.isEmpty) {
       if (state.allUniqueAssets.isEmpty) {
-        debugPrint("No Asset On Device - Abort Backup Process");
+        log.info("No Asset On Device - Abort Backup Process");
         state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
         state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
         return;
         return;
       }
       }
 
 
       Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
       Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
-
       // Remove item that has already been backed up
       // Remove item that has already been backed up
       for (var assetId in state.allAssetsInDatabase) {
       for (var assetId in state.allAssetsInDatabase) {
         assetsWillBeBackup.removeWhere((e) => e.id == assetId);
         assetsWillBeBackup.removeWhere((e) => e.id == assetId);
@@ -530,7 +536,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 
 
     // User has been logged out return
     // User has been logged out return
     if (accessKey == null || !_authState.isAuthenticated) {
     if (accessKey == null || !_authState.isAuthenticated) {
-      debugPrint("[resumeBackup] not authenticated - abort");
+      log.info("[_resumeBackup] not authenticated - abort");
       return;
       return;
     }
     }
 
 
@@ -539,17 +545,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
         _authState.deviceInfo.isAutoBackup) {
         _authState.deviceInfo.isAutoBackup) {
       // check if backup is alreayd in process - then return
       // check if backup is alreayd in process - then return
       if (state.backupProgress == BackUpProgressEnum.inProgress) {
       if (state.backupProgress == BackUpProgressEnum.inProgress) {
-        debugPrint("[resumeBackup] Backup is already in progress - abort");
+        log.info("[_resumeBackup] Backup is already in progress - abort");
         return;
         return;
       }
       }
 
 
       if (state.backupProgress == BackUpProgressEnum.inBackground) {
       if (state.backupProgress == BackUpProgressEnum.inBackground) {
-        debugPrint("[resumeBackup] Background backup is running - abort");
+        log.info("[_resumeBackup] Background backup is running - abort");
         return;
         return;
       }
       }
 
 
       // Run backup
       // Run backup
-      debugPrint("[resumeBackup] Start back up");
+      log.info("[_resumeBackup] Start back up");
       await startBackupProcess();
       await startBackupProcess();
     }
     }
 
 
@@ -565,7 +571,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
       state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
       state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
       final bool hasLock = await _backgroundService.acquireLock();
       final bool hasLock = await _backgroundService.acquireLock();
       if (!hasLock) {
       if (!hasLock) {
-        debugPrint("WARNING [resumeBackup] failed to acquireLock");
+        log.warning("WARNING [resumeBackup] failed to acquireLock");
         return;
         return;
       }
       }
       await Future.wait([
       await Future.wait([
@@ -612,7 +618,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
         AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
         AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
         result.add(a.copyWith(lastBackup: times[i]));
         result.add(a.copyWith(lastBackup: times[i]));
       } on StateError {
       } on StateError {
-        debugPrint("[_updateAlbumBackupTime] failed to find album in state");
+        log.severe(
+          "[_updateAlbumBackupTime] failed to find album in state",
+          "State Error",
+          StackTrace.current,
+        );
       }
       }
     }
     }
     return result;
     return result;
@@ -631,21 +641,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
           await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
           await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
         }
         }
       } catch (error) {
       } catch (error) {
-        debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
+        log.info("[_notifyBackgroundServiceCanRun] failed to close box");
       }
       }
       try {
       try {
         if (Hive.isBoxOpen(duplicatedAssetsBox)) {
         if (Hive.isBoxOpen(duplicatedAssetsBox)) {
           await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
           await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
         }
         }
-      } catch (error) {
-        debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
+      } catch (error, stackTrace) {
+        log.severe(
+          "[_notifyBackgroundServiceCanRun] failed to close box",
+          error,
+          stackTrace,
+        );
       }
       }
       try {
       try {
         if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
         if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
           await Hive.box(backgroundBackupInfoBox).close();
           await Hive.box(backgroundBackupInfoBox).close();
         }
         }
-      } catch (error) {
-        debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
+      } catch (error, stackTrace) {
+        log.severe(
+          "[_notifyBackgroundServiceCanRun] failed to close box",
+          error,
+          stackTrace,
+        );
       }
       }
       _backgroundService.releaseLock();
       _backgroundService.releaseLock();
     }
     }

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

@@ -33,7 +33,7 @@ class AlbumInfoCard extends HookConsumerWidget {
     ColorFilter unselectedFilter =
     ColorFilter unselectedFilter =
         const ColorFilter.mode(Colors.black, BlendMode.color);
         const ColorFilter.mode(Colors.black, BlendMode.color);
 
 
-    _buildSelectedTextBox() {
+    buildSelectedTextBox() {
       if (isSelected) {
       if (isSelected) {
         return Chip(
         return Chip(
           visualDensity: VisualDensity.compact,
           visualDensity: VisualDensity.compact,
@@ -67,7 +67,7 @@ class AlbumInfoCard extends HookConsumerWidget {
       return const SizedBox();
       return const SizedBox();
     }
     }
 
 
-    _buildImageFilter() {
+    buildImageFilter() {
       if (isSelected) {
       if (isSelected) {
         return selectedFilter;
         return selectedFilter;
       } else if (isExcluded) {
       } else if (isExcluded) {
@@ -163,7 +163,7 @@ class AlbumInfoCard extends HookConsumerWidget {
                       topRight: Radius.circular(12),
                       topRight: Radius.circular(12),
                     ),
                     ),
                     image: DecorationImage(
                     image: DecorationImage(
-                      colorFilter: _buildImageFilter(),
+                      colorFilter: buildImageFilter(),
                       image: imageData != null
                       image: imageData != null
                           ? MemoryImage(imageData!)
                           ? MemoryImage(imageData!)
                           : const AssetImage(
                           : const AssetImage(
@@ -177,7 +177,7 @@ class AlbumInfoCard extends HookConsumerWidget {
                 Positioned(
                 Positioned(
                   bottom: 10,
                   bottom: 10,
                   left: 25,
                   left: 25,
-                  child: _buildSelectedTextBox(),
+                  child: buildSelectedTextBox(),
                 )
                 )
               ],
               ],
             ),
             ),

+ 6 - 4
mobile/lib/modules/backup/views/album_preview_page.dart

@@ -15,14 +15,16 @@ class AlbumPreviewPage extends HookConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
     final assets = useState<List<AssetEntity>>([]);
     final assets = useState<List<AssetEntity>>([]);
 
 
-    _getAssetsInAlbum() async {
+    getAssetsInAlbum() async {
       assets.value = await album.getAssetListRange(
       assets.value = await album.getAssetListRange(
-          start: 0, end: await album.assetCountAsync);
+        start: 0,
+        end: await album.assetCountAsync,
+      );
     }
     }
 
 
     useEffect(
     useEffect(
       () {
       () {
-        _getAssetsInAlbum();
+        getAssetsInAlbum();
         return null;
         return null;
       },
       },
       [],
       [],
@@ -34,7 +36,7 @@ class AlbumPreviewPage extends HookConsumerWidget {
         title: Column(
         title: Column(
           children: [
           children: [
             Text(
             Text(
-              "${album.name} (${album.assetCountAsync})",
+              album.name,
               style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
               style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
             ),
             ),
             Padding(
             Padding(

+ 67 - 20
mobile/lib/modules/backup/views/backup_album_selection_page.dart

@@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/immich_colors.dart';
 import 'package:immich_mobile/constants/immich_colors.dart';
+import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
 import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
 import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@@ -14,10 +15,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
   const BackupAlbumSelectionPage({Key? key}) : super(key: key);
   const BackupAlbumSelectionPage({Key? key}) : super(key: key);
   @override
   @override
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
-    final availableAlbums = ref.watch(backupProvider).availableAlbums;
+    // final availableAlbums = ref.watch(backupProvider).availableAlbums;
     final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
     final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
     final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
     final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
     final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
     final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
+    final albums = useState<List<AvailableAlbum>>(
+      ref.watch(backupProvider).availableAlbums,
+    );
 
 
     useEffect(
     useEffect(
       () {
       () {
@@ -27,8 +31,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
       [],
       [],
     );
     );
 
 
-    _buildAlbumSelectionList() {
-      if (availableAlbums.isEmpty) {
+    buildAlbumSelectionList() {
+      if (albums.value.isEmpty) {
         return const Center(
         return const Center(
           child: ImmichLoadingIndicator(),
           child: ImmichLoadingIndicator(),
         );
         );
@@ -38,17 +42,17 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
         height: 265,
         height: 265,
         child: ListView.builder(
         child: ListView.builder(
           scrollDirection: Axis.horizontal,
           scrollDirection: Axis.horizontal,
-          itemCount: availableAlbums.length,
+          itemCount: albums.value.length,
           physics: const BouncingScrollPhysics(),
           physics: const BouncingScrollPhysics(),
           itemBuilder: ((context, index) {
           itemBuilder: ((context, index) {
-            var thumbnailData = availableAlbums[index].thumbnailData;
+            var thumbnailData = albums.value[index].thumbnailData;
             return Padding(
             return Padding(
               padding: index == 0
               padding: index == 0
                   ? const EdgeInsets.only(left: 16.00)
                   ? const EdgeInsets.only(left: 16.00)
                   : const EdgeInsets.all(0),
                   : const EdgeInsets.all(0),
               child: AlbumInfoCard(
               child: AlbumInfoCard(
                 imageData: thumbnailData,
                 imageData: thumbnailData,
-                albumInfo: availableAlbums[index],
+                albumInfo: albums.value[index],
               ),
               ),
             );
             );
           }),
           }),
@@ -56,7 +60,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildSelectedAlbumNameChip() {
+    buildSelectedAlbumNameChip() {
       return selectedBackupAlbums.map((album) {
       return selectedBackupAlbums.map((album) {
         void removeSelection() {
         void removeSelection() {
           if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
           if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
@@ -79,15 +83,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
             child: Chip(
             child: Chip(
               visualDensity: VisualDensity.compact,
               visualDensity: VisualDensity.compact,
               shape: RoundedRectangleBorder(
               shape: RoundedRectangleBorder(
-                borderRadius: BorderRadius.circular(5),
+                borderRadius: BorderRadius.circular(10),
               ),
               ),
               label: Text(
               label: Text(
                 album.name,
                 album.name,
                 style: TextStyle(
                 style: TextStyle(
                   fontSize: 10,
                   fontSize: 10,
-                  color: Theme.of(context).brightness == Brightness.dark
-                      ? Colors.black
-                      : Colors.white,
+                  color: isDarkTheme ? Colors.black : Colors.white,
                   fontWeight: FontWeight.bold,
                   fontWeight: FontWeight.bold,
                 ),
                 ),
               ),
               ),
@@ -104,7 +106,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
       }).toSet();
       }).toSet();
     }
     }
 
 
-    _buildExcludedAlbumNameChip() {
+    buildExcludedAlbumNameChip() {
       return excludedBackupAlbums.map((album) {
       return excludedBackupAlbums.map((album) {
         void removeSelection() {
         void removeSelection() {
           ref
           ref
@@ -119,7 +121,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
             child: Chip(
             child: Chip(
               visualDensity: VisualDensity.compact,
               visualDensity: VisualDensity.compact,
               shape: RoundedRectangleBorder(
               shape: RoundedRectangleBorder(
-                borderRadius: BorderRadius.circular(5),
+                borderRadius: BorderRadius.circular(10),
               ),
               ),
               label: Text(
               label: Text(
                 album.name,
                 album.name,
@@ -143,6 +145,46 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
       }).toSet();
       }).toSet();
     }
     }
 
 
+    buildSearchBar() {
+      return Padding(
+        padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
+        child: TextFormField(
+          onChanged: (searchValue) {
+            albums.value = ref
+                .watch(backupProvider)
+                .availableAlbums
+                .where(
+                  (album) => album.name
+                      .toLowerCase()
+                      .contains(searchValue.toLowerCase()),
+                )
+                .toList();
+          },
+          decoration: InputDecoration(
+            contentPadding: const EdgeInsets.symmetric(
+              horizontal: 8.0,
+              vertical: 8.0,
+            ),
+            hintText: "Search",
+            hintStyle: TextStyle(
+              color: isDarkTheme ? Colors.white : Colors.grey,
+              fontSize: 14.0,
+            ),
+            prefixIcon: const Icon(
+              Icons.search,
+              color: Colors.grey,
+            ),
+            border: OutlineInputBorder(
+              borderRadius: BorderRadius.circular(10),
+              borderSide: BorderSide.none,
+            ),
+            filled: true,
+            fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
+          ),
+        ),
+      );
+    }
+
     return Scaffold(
     return Scaffold(
       appBar: AppBar(
       appBar: AppBar(
         leading: IconButton(
         leading: IconButton(
@@ -177,8 +219,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
             padding: const EdgeInsets.symmetric(horizontal: 16.0),
             padding: const EdgeInsets.symmetric(horizontal: 16.0),
             child: Wrap(
             child: Wrap(
               children: [
               children: [
-                ..._buildSelectedAlbumNameChip(),
-                ..._buildExcludedAlbumNameChip()
+                ...buildSelectedAlbumNameChip(),
+                ...buildExcludedAlbumNameChip()
               ],
               ],
             ),
             ),
           ),
           ),
@@ -188,7 +230,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
             child: Card(
             child: Card(
               margin: const EdgeInsets.all(0),
               margin: const EdgeInsets.all(0),
               shape: RoundedRectangleBorder(
               shape: RoundedRectangleBorder(
-                borderRadius: BorderRadius.circular(5),
+                borderRadius: BorderRadius.circular(10),
                 side: BorderSide(
                 side: BorderSide(
                   color: isDarkTheme
                   color: isDarkTheme
                       ? const Color.fromARGB(255, 0, 0, 0)
                       ? const Color.fromARGB(255, 0, 0, 0)
@@ -225,8 +267,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
 
 
           ListTile(
           ListTile(
             title: Text(
             title: Text(
-              "backup_album_selection_page_albums_device"
-                  .tr(args: [availableAlbums.length.toString()]),
+              "backup_album_selection_page_albums_device".tr(
+                args: [
+                  ref.watch(backupProvider).availableAlbums.length.toString()
+                ],
+              ),
               style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
               style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
             ),
             ),
             subtitle: Padding(
             subtitle: Padding(
@@ -254,7 +299,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
                   builder: (BuildContext context) {
                   builder: (BuildContext context) {
                     return AlertDialog(
                     return AlertDialog(
                       shape: RoundedRectangleBorder(
                       shape: RoundedRectangleBorder(
-                        borderRadius: BorderRadius.circular(12),
+                        borderRadius: BorderRadius.circular(10),
                       ),
                       ),
                       elevation: 5,
                       elevation: 5,
                       title: Text(
                       title: Text(
@@ -284,9 +329,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
             ),
             ),
           ),
           ),
 
 
+          buildSearchBar(),
+
           Padding(
           Padding(
             padding: const EdgeInsets.only(bottom: 16.0),
             padding: const EdgeInsets.only(bottom: 16.0),
-            child: _buildAlbumSelectionList(),
+            child: buildAlbumSelectionList(),
           ),
           ),
         ],
         ],
       ),
       ),

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

@@ -45,7 +45,7 @@ class BackupControllerPage extends HookConsumerWidget {
       [],
       [],
     );
     );
 
 
-    Widget _buildStorageInformation() {
+    Widget buildStorageInformation() {
       return ListTile(
       return ListTile(
         leading: Icon(
         leading: Icon(
           Icons.storage_rounded,
           Icons.storage_rounded,
@@ -84,7 +84,7 @@ class BackupControllerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    ListTile _buildAutoBackupController() {
+    ListTile buildAutoBackupController() {
       var backUpOption = authenticationState.deviceInfo.isAutoBackup
       var backUpOption = authenticationState.deviceInfo.isAutoBackup
           ? "backup_controller_page_status_on".tr()
           ? "backup_controller_page_status_on".tr()
           : "backup_controller_page_status_off".tr();
           : "backup_controller_page_status_off".tr();
@@ -143,7 +143,7 @@ class BackupControllerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    void _showErrorToUser(String msg) {
+    void showErrorToUser(String msg) {
       final snackBar = SnackBar(
       final snackBar = SnackBar(
         content: Text(
         content: Text(
           msg.tr(),
           msg.tr(),
@@ -153,7 +153,7 @@ class BackupControllerPage extends HookConsumerWidget {
       ScaffoldMessenger.of(context).showSnackBar(snackBar);
       ScaffoldMessenger.of(context).showSnackBar(snackBar);
     }
     }
 
 
-    void _showBatteryOptimizationInfoToUser() {
+    void showBatteryOptimizationInfoToUser() {
       showDialog<void>(
       showDialog<void>(
         context: context,
         context: context,
         barrierDismissible: false,
         barrierDismissible: false,
@@ -193,7 +193,7 @@ class BackupControllerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    ListTile _buildBackgroundBackupController() {
+    ListTile buildBackgroundBackupController() {
       final bool isBackgroundEnabled = backupState.backgroundBackup;
       final bool isBackgroundEnabled = backupState.backgroundBackup;
       final bool isWifiRequired = backupState.backupRequireWifi;
       final bool isWifiRequired = backupState.backupRequireWifi;
       final bool isChargingRequired = backupState.backupRequireCharging;
       final bool isChargingRequired = backupState.backupRequireCharging;
@@ -238,8 +238,8 @@ class BackupControllerPage extends HookConsumerWidget {
                         .read(backupProvider.notifier)
                         .read(backupProvider.notifier)
                         .configureBackgroundBackup(
                         .configureBackgroundBackup(
                           requireWifi: isChecked,
                           requireWifi: isChecked,
-                          onError: _showErrorToUser,
-                          onBatteryInfo: _showBatteryOptimizationInfoToUser,
+                          onError: showErrorToUser,
+                          onBatteryInfo: showBatteryOptimizationInfoToUser,
                         )
                         )
                     : null,
                     : null,
               ),
               ),
@@ -259,8 +259,8 @@ class BackupControllerPage extends HookConsumerWidget {
                         .read(backupProvider.notifier)
                         .read(backupProvider.notifier)
                         .configureBackgroundBackup(
                         .configureBackgroundBackup(
                           requireCharging: isChecked,
                           requireCharging: isChecked,
-                          onError: _showErrorToUser,
-                          onBatteryInfo: _showBatteryOptimizationInfoToUser,
+                          onError: showErrorToUser,
+                          onBatteryInfo: showBatteryOptimizationInfoToUser,
                         )
                         )
                     : null,
                     : null,
               ),
               ),
@@ -268,8 +268,8 @@ class BackupControllerPage extends HookConsumerWidget {
               onPressed: () =>
               onPressed: () =>
                   ref.read(backupProvider.notifier).configureBackgroundBackup(
                   ref.read(backupProvider.notifier).configureBackgroundBackup(
                         enabled: !isBackgroundEnabled,
                         enabled: !isBackgroundEnabled,
-                        onError: _showErrorToUser,
-                        onBatteryInfo: _showBatteryOptimizationInfoToUser,
+                        onError: showErrorToUser,
+                        onBatteryInfo: showBatteryOptimizationInfoToUser,
                       ),
                       ),
               child: Text(
               child: Text(
                 isBackgroundEnabled
                 isBackgroundEnabled
@@ -284,7 +284,7 @@ class BackupControllerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    Widget _buildSelectedAlbumName() {
+    Widget buildSelectedAlbumName() {
       var text = "backup_controller_page_backup_selected".tr();
       var text = "backup_controller_page_backup_selected".tr();
       var albums = ref.watch(backupProvider).selectedBackupAlbums;
       var albums = ref.watch(backupProvider).selectedBackupAlbums;
 
 
@@ -323,7 +323,7 @@ class BackupControllerPage extends HookConsumerWidget {
       }
       }
     }
     }
 
 
-    Widget _buildExcludedAlbumName() {
+    Widget buildExcludedAlbumName() {
       var text = "backup_controller_page_excluded".tr();
       var text = "backup_controller_page_excluded".tr();
       var albums = ref.watch(backupProvider).excludedBackupAlbums;
       var albums = ref.watch(backupProvider).excludedBackupAlbums;
 
 
@@ -348,7 +348,7 @@ class BackupControllerPage extends HookConsumerWidget {
       }
       }
     }
     }
 
 
-    _buildFolderSelectionTile() {
+    buildFolderSelectionTile() {
       return Card(
       return Card(
         shape: RoundedRectangleBorder(
         shape: RoundedRectangleBorder(
           borderRadius: BorderRadius.circular(5), // if you need this
           borderRadius: BorderRadius.circular(5), // if you need this
@@ -374,8 +374,8 @@ class BackupControllerPage extends HookConsumerWidget {
                   "backup_controller_page_to_backup",
                   "backup_controller_page_to_backup",
                   style: TextStyle(fontSize: 12),
                   style: TextStyle(fontSize: 12),
                 ).tr(),
                 ).tr(),
-                _buildSelectedAlbumName(),
-                _buildExcludedAlbumName()
+                buildSelectedAlbumName(),
+                buildExcludedAlbumName()
               ],
               ],
             ),
             ),
           ),
           ),
@@ -398,7 +398,7 @@ class BackupControllerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildCurrentBackupAssetInfoCard() {
+    buildCurrentBackupAssetInfoCard() {
       return ListTile(
       return ListTile(
         leading: Icon(
         leading: Icon(
           Icons.info_outline_rounded,
           Icons.info_outline_rounded,
@@ -606,7 +606,7 @@ class BackupControllerPage extends HookConsumerWidget {
                       ),
                       ),
                     ),
                     ),
                   ),
                   ),
-            _buildFolderSelectionTile(),
+            buildFolderSelectionTile(),
             BackupInfoCard(
             BackupInfoCard(
               title: "backup_controller_page_total".tr(),
               title: "backup_controller_page_total".tr(),
               subtitle: "backup_controller_page_total_sub".tr(),
               subtitle: "backup_controller_page_total_sub".tr(),
@@ -624,13 +624,13 @@ class BackupControllerPage extends HookConsumerWidget {
                   "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
                   "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
             ),
             ),
             const Divider(),
             const Divider(),
-            _buildAutoBackupController(),
+            buildAutoBackupController(),
             if (Platform.isAndroid) const Divider(),
             if (Platform.isAndroid) const Divider(),
-            if (Platform.isAndroid) _buildBackgroundBackupController(),
+            if (Platform.isAndroid) buildBackgroundBackupController(),
             const Divider(),
             const Divider(),
-            _buildStorageInformation(),
+            buildStorageInformation(),
             const Divider(),
             const Divider(),
-            _buildCurrentBackupAssetInfoCard(),
+            buildCurrentBackupAssetInfoCard(),
             Padding(
             Padding(
               padding: const EdgeInsets.only(
               padding: const EdgeInsets.only(
                 top: 24,
                 top: 24,

+ 26 - 34
mobile/lib/modules/home/services/asset.service.dart

@@ -10,8 +10,10 @@ import 'package:immich_mobile/modules/backup/services/backup.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:immich_mobile/utils/openapi_extensions.dart';
+import 'package:immich_mobile/utils/tuple.dart';
+import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
-import 'package:photo_manager/src/types/entity.dart';
 
 
 final assetServiceProvider = Provider(
 final assetServiceProvider = Provider(
   (ref) => AssetService(
   (ref) => AssetService(
@@ -25,42 +27,31 @@ class AssetService {
   final ApiService _apiService;
   final ApiService _apiService;
   final BackupService _backupService;
   final BackupService _backupService;
   final BackgroundService _backgroundService;
   final BackgroundService _backgroundService;
+  final log = Logger('AssetService');
 
 
   AssetService(this._apiService, this._backupService, this._backgroundService);
   AssetService(this._apiService, this._backupService, this._backgroundService);
 
 
-  /// Returns all local, remote assets in that order
-  Future<List<Asset>> getAllAsset({bool urgent = false}) async {
-    final List<Asset> assets = [];
+  /// Returns `null` if the server state did not change, else list of assets
+  Future<List<Asset>?> getRemoteAssets({required bool hasCache}) async {
     try {
     try {
-      // not using `await` here to fetch local & remote assets concurrently
-      final Future<List<AssetResponseDto>?> remoteTask =
-          _apiService.assetApi.getAllAssets();
-      final Iterable<AssetEntity> newLocalAssets;
-      final List<AssetEntity> localAssets = await _getLocalAssets(urgent);
-      final List<AssetResponseDto> remoteAssets = await remoteTask ?? [];
-      if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) {
-        final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
-        final Set<String> existingIds = remoteAssets
-            .where((e) => e.deviceId == deviceId)
-            .map((e) => e.deviceAssetId)
-            .toSet();
-        newLocalAssets = localAssets.where((e) => !existingIds.contains(e.id));
-      } else {
-        newLocalAssets = localAssets;
+      final Box box = Hive.box(userInfoBox);
+      final Pair<List<AssetResponseDto>, String?>? remote = await _apiService
+          .assetApi
+          .getAllAssetsWithETag(eTag: hasCache ? box.get(assetEtagKey) : null);
+      if (remote == null) {
+        return null;
       }
       }
-
-      assets.addAll(newLocalAssets.map((e) => Asset.local(e)));
-      // the order (first all local, then remote assets) is important!
-      assets.addAll(remoteAssets.map((e) => Asset.remote(e)));
-    } catch (e) {
-      debugPrint("Error [getAllAsset]  ${e.toString()}");
+      box.put(assetEtagKey, remote.second);
+      return remote.first.map(Asset.remote).toList(growable: false);
+    } catch (e, stack) {
+      log.severe('Error while getting remote assets', e, stack);
+      return null;
     }
     }
-    return assets;
   }
   }
 
 
   /// if [urgent] is `true`, do not block by waiting on the background service
   /// if [urgent] is `true`, do not block by waiting on the background service
-  /// to finish running. Returns an empty list instead after a timeout.
-  Future<List<AssetEntity>> _getLocalAssets(bool urgent) async {
+  /// to finish running. Returns `null` instead after a timeout.
+  Future<List<Asset>?> getLocalAssets({bool urgent = false}) async {
     try {
     try {
       final Future<bool> hasAccess = urgent
       final Future<bool> hasAccess = urgent
           ? _backgroundService.hasAccess
           ? _backgroundService.hasAccess
@@ -71,15 +62,16 @@ class AssetService {
       }
       }
       final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
       final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
       final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
       final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
-
-      return backupAlbumInfo != null
-          ? await _backupService
-              .buildUploadCandidates(backupAlbumInfo.deepCopy())
-          : [];
+      if (backupAlbumInfo != null) {
+        return (await _backupService
+                .buildUploadCandidates(backupAlbumInfo.deepCopy()))
+            .map(Asset.local)
+            .toList(growable: false);
+      }
     } catch (e) {
     } catch (e) {
       debugPrint("Error [_getLocalAssets] ${e.toString()}");
       debugPrint("Error [_getLocalAssets] ${e.toString()}");
-      return [];
     }
     }
+    return null;
   }
   }
 
 
   Future<Asset?> getAssetById(String assetId) async {
   Future<Asset?> getAssetById(String assetId) async {

+ 3 - 1
mobile/lib/modules/home/ui/immich_sliver_appbar.dart

@@ -32,7 +32,9 @@ class ImmichSliverAppBar extends ConsumerWidget {
       snap: false,
       snap: false,
       backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
       backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
       shape: const RoundedRectangleBorder(
       shape: const RoundedRectangleBorder(
-        borderRadius: BorderRadius.all(Radius.circular(5)),
+        borderRadius: BorderRadius.all(
+          Radius.circular(5),
+        ),
       ),
       ),
       leading: Builder(
       leading: Builder(
         builder: (BuildContext context) {
         builder: (BuildContext context) {

+ 31 - 6
mobile/lib/modules/home/ui/profile_drawer/profile_drawer.dart

@@ -2,12 +2,12 @@ import 'package:auto_route/auto_route.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer_header.dart';
 import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer_header.dart';
 import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dart';
 import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dart';
+import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
-import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
-import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 
 
 class ProfileDrawer extends HookConsumerWidget {
 class ProfileDrawer extends HookConsumerWidget {
@@ -15,7 +15,7 @@ class ProfileDrawer extends HookConsumerWidget {
 
 
   @override
   @override
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
-    _buildSignoutButton() {
+    buildSignoutButton() {
       return ListTile(
       return ListTile(
         horizontalTitleGap: 0,
         horizontalTitleGap: 0,
         leading: SizedBox(
         leading: SizedBox(
@@ -46,7 +46,7 @@ class ProfileDrawer extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildSettingButton() {
+    buildSettingButton() {
       return ListTile(
       return ListTile(
         horizontalTitleGap: 0,
         horizontalTitleGap: 0,
         leading: SizedBox(
         leading: SizedBox(
@@ -70,6 +70,30 @@ class ProfileDrawer extends HookConsumerWidget {
       );
       );
     }
     }
 
 
+    buildAppLogButton() {
+      return ListTile(
+        horizontalTitleGap: 0,
+        leading: SizedBox(
+          height: double.infinity,
+          child: Icon(
+            Icons.assignment_outlined,
+            color: Theme.of(context).textTheme.labelMedium?.color,
+            size: 20,
+          ),
+        ),
+        title: Text(
+          "profile_drawer_app_logs",
+          style: Theme.of(context)
+              .textTheme
+              .labelLarge
+              ?.copyWith(fontWeight: FontWeight.bold),
+        ).tr(),
+        onTap: () {
+          AutoRouter.of(context).push(const AppLogRoute());
+        },
+      );
+    }
+
     return Drawer(
     return Drawer(
       child: Column(
       child: Column(
         mainAxisAlignment: MainAxisAlignment.spaceBetween,
         mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -79,8 +103,9 @@ class ProfileDrawer extends HookConsumerWidget {
             padding: EdgeInsets.zero,
             padding: EdgeInsets.zero,
             children: [
             children: [
               const ProfileDrawerHeader(),
               const ProfileDrawerHeader(),
-              _buildSettingButton(),
-              _buildSignoutButton(),
+              buildSettingButton(),
+              buildAppLogButton(),
+              buildSignoutButton(),
             ],
             ],
           ),
           ),
           const ServerInfoBox()
           const ServerInfoBox()

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

@@ -25,7 +25,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
     var dummmy = Random().nextInt(1024);
     var dummmy = Random().nextInt(1024);
     final isDarkMode = Theme.of(context).brightness == Brightness.dark;
     final isDarkMode = Theme.of(context).brightness == Brightness.dark;
 
 
-    _buildUserProfileImage() {
+    buildUserProfileImage() {
       if (authState.profileImagePath.isEmpty) {
       if (authState.profileImagePath.isEmpty) {
         return const CircleAvatar(
         return const CircleAvatar(
           radius: 35,
           radius: 35,
@@ -77,7 +77,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
       return const SizedBox();
       return const SizedBox();
     }
     }
 
 
-    _pickUserProfileImage() async {
+    pickUserProfileImage() async {
       final XFile? image = await ImagePicker().pickImage(
       final XFile? image = await ImagePicker().pickImage(
         source: ImageSource.gallery,
         source: ImageSource.gallery,
         maxHeight: 1024,
         maxHeight: 1024,
@@ -98,7 +98,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
 
 
     useEffect(
     useEffect(
       () {
       () {
-        _buildUserProfileImage();
+        buildUserProfileImage();
         return null;
         return null;
       },
       },
       [],
       [],
@@ -129,12 +129,12 @@ class ProfileDrawerHeader extends HookConsumerWidget {
           Stack(
           Stack(
             clipBehavior: Clip.none,
             clipBehavior: Clip.none,
             children: [
             children: [
-              _buildUserProfileImage(),
+              buildUserProfileImage(),
               Positioned(
               Positioned(
                 bottom: 0,
                 bottom: 0,
                 right: -5,
                 right: -5,
                 child: GestureDetector(
                 child: GestureDetector(
-                  onTap: _pickUserProfileImage,
+                  onTap: pickUserProfileImage,
                   child: Material(
                   child: Material(
                     color: Colors.grey[100],
                     color: Colors.grey[100],
                     elevation: 3,
                     elevation: 3,

+ 2 - 2
mobile/lib/modules/home/ui/profile_drawer/server_info_box.dart

@@ -17,7 +17,7 @@ class ServerInfoBox extends HookConsumerWidget {
 
 
     final appInfo = useState({});
     final appInfo = useState({});
 
 
-    _getPackageInfo() async {
+    getPackageInfo() async {
       PackageInfo packageInfo = await PackageInfo.fromPlatform();
       PackageInfo packageInfo = await PackageInfo.fromPlatform();
 
 
       appInfo.value = {
       appInfo.value = {
@@ -28,7 +28,7 @@ class ServerInfoBox extends HookConsumerWidget {
 
 
     useEffect(
     useEffect(
       () {
       () {
-        _getPackageInfo();
+        getPackageInfo();
         return null;
         return null;
       },
       },
       [],
       [],

+ 59 - 9
mobile/lib/modules/home/views/home_page.dart

@@ -1,3 +1,5 @@
+import 'dart:async';
+
 import 'package:auto_route/auto_route.dart';
 import 'package:auto_route/auto_route.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
@@ -20,6 +22,7 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 import 'package:immich_mobile/shared/services/share.service.dart';
 import 'package:immich_mobile/shared/services/share.service.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 
 
@@ -37,6 +40,8 @@ class HomePage extends HookConsumerWidget {
     final albums = ref.watch(albumProvider);
     final albums = ref.watch(albumProvider);
     final albumService = ref.watch(albumServiceProvider);
     final albumService = ref.watch(albumServiceProvider);
 
 
+    final tipOneOpacity = useState(0.0);
+
     useEffect(
     useEffect(
       () {
       () {
         ref.read(websocketProvider.notifier).connect();
         ref.read(websocketProvider.notifier).connect();
@@ -146,6 +151,49 @@ class HomePage extends HookConsumerWidget {
         }
         }
       }
       }
 
 
+      buildLoadingIndicator() {
+        Timer(const Duration(seconds: 2), () {
+          tipOneOpacity.value = 1;
+        });
+
+        return Center(
+          child: Column(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: [
+              const ImmichLoadingIndicator(),
+              Padding(
+                padding: const EdgeInsets.only(top: 16.0),
+                child: Text(
+                  'Building the timeline',
+                  style: TextStyle(
+                    fontWeight: FontWeight.w600,
+                    fontSize: 16,
+                    color: Theme.of(context).primaryColor,
+                  ),
+                ),
+              ),
+              AnimatedOpacity(
+                duration: const Duration(milliseconds: 500),
+                opacity: tipOneOpacity.value,
+                child: const SizedBox(
+                  width: 250,
+                  child: Padding(
+                    padding: EdgeInsets.only(top: 8.0),
+                    child: Text(
+                      'If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).',
+                      textAlign: TextAlign.justify,
+                      style: TextStyle(
+                        fontSize: 12,
+                      ),
+                    ),
+                  ),
+                ),
+              )
+            ],
+          ),
+        );
+      }
+
       return SafeArea(
       return SafeArea(
         bottom: !multiselectEnabled.state,
         bottom: !multiselectEnabled.state,
         top: true,
         top: true,
@@ -164,15 +212,17 @@ class HomePage extends HookConsumerWidget {
                 top: selectionEnabledHook.value ? 0 : 60,
                 top: selectionEnabledHook.value ? 0 : 60,
                 bottom: 0.0,
                 bottom: 0.0,
               ),
               ),
-              child: ImmichAssetGrid(
-                renderList: renderList,
-                assetsPerRow:
-                    appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
-                showStorageIndicator: appSettingService
-                    .getSetting(AppSettingsEnum.storageIndicator),
-                listener: selectionListener,
-                selectionActive: selectionEnabledHook.value,
-              ),
+              child: ref.watch(assetProvider).isEmpty
+                  ? buildLoadingIndicator()
+                  : ImmichAssetGrid(
+                      renderList: renderList,
+                      assetsPerRow: appSettingService
+                          .getSetting(AppSettingsEnum.tilesPerRow),
+                      showStorageIndicator: appSettingService
+                          .getSetting(AppSettingsEnum.storageIndicator),
+                      listener: selectionListener,
+                      selectionActive: selectionEnabledHook.value,
+                    ),
             ),
             ),
             if (selectionEnabledHook.value)
             if (selectionEnabledHook.value)
               ControlBottomAppBar(
               ControlBottomAppBar(

+ 7 - 3
mobile/lib/modules/login/models/hive_saved_login_info.model.dart

@@ -5,21 +5,25 @@ part 'hive_saved_login_info.model.g.dart';
 @HiveType(typeId: 0)
 @HiveType(typeId: 0)
 class HiveSavedLoginInfo {
 class HiveSavedLoginInfo {
   @HiveField(0)
   @HiveField(0)
-  String email;
+  String email; // DEPRECATED
 
 
   @HiveField(1)
   @HiveField(1)
-  String password;
+  String password; // DEPRECATED
 
 
   @HiveField(2)
   @HiveField(2)
   String serverUrl;
   String serverUrl;
 
 
-  @HiveField(3)
+  @HiveField(3, defaultValue: false)
   bool isSaveLogin;
   bool isSaveLogin;
 
 
+  @HiveField(4, defaultValue: "")
+  String accessToken;
+
   HiveSavedLoginInfo({
   HiveSavedLoginInfo({
     required this.email,
     required this.email,
     required this.password,
     required this.password,
     required this.serverUrl,
     required this.serverUrl,
     required this.isSaveLogin,
     required this.isSaveLogin,
+    required this.accessToken,
   });
   });
 }
 }

+ 6 - 3
mobile/lib/modules/login/models/hive_saved_login_info.model.g.dart

@@ -20,14 +20,15 @@ class HiveSavedLoginInfoAdapter extends TypeAdapter<HiveSavedLoginInfo> {
       email: fields[0] as String,
       email: fields[0] as String,
       password: fields[1] as String,
       password: fields[1] as String,
       serverUrl: fields[2] as String,
       serverUrl: fields[2] as String,
-      isSaveLogin: fields[3] as bool,
+      isSaveLogin: fields[3] == null ? false : fields[3] as bool,
+      accessToken: fields[4] == null ? '' : fields[4] as String,
     );
     );
   }
   }
 
 
   @override
   @override
   void write(BinaryWriter writer, HiveSavedLoginInfo obj) {
   void write(BinaryWriter writer, HiveSavedLoginInfo obj) {
     writer
     writer
-      ..writeByte(4)
+      ..writeByte(5)
       ..writeByte(0)
       ..writeByte(0)
       ..write(obj.email)
       ..write(obj.email)
       ..writeByte(1)
       ..writeByte(1)
@@ -35,7 +36,9 @@ class HiveSavedLoginInfoAdapter extends TypeAdapter<HiveSavedLoginInfo> {
       ..writeByte(2)
       ..writeByte(2)
       ..write(obj.serverUrl)
       ..write(obj.serverUrl)
       ..writeByte(3)
       ..writeByte(3)
-      ..write(obj.isSaveLogin);
+      ..write(obj.isSaveLogin)
+      ..writeByte(4)
+      ..write(obj.accessToken);
   }
   }
 
 
   @override
   @override

+ 82 - 67
mobile/lib/modules/login/providers/authentication.provider.dart

@@ -74,15 +74,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
       return false;
       return false;
     }
     }
 
 
-    // Store device id to local storage
-    var deviceInfo = await _deviceInfoService.getDeviceInfo();
-    Hive.box(userInfoBox).put(deviceIdKey, deviceInfo["deviceId"]);
-
-    state = state.copyWith(
-      deviceId: deviceInfo["deviceId"],
-      deviceType: deviceInfo["deviceType"],
-    );
-
     // Make sign-in request
     // Make sign-in request
     try {
     try {
       var loginResponse = await _apiService.authenticationApi.login(
       var loginResponse = await _apiService.authenticationApi.login(
@@ -97,73 +88,27 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
         return false;
         return false;
       }
       }
 
 
-      Hive.box(userInfoBox).put(accessTokenKey, loginResponse.accessToken);
-
-      state = state.copyWith(
-        isAuthenticated: true,
-        userId: loginResponse.userId,
-        userEmail: loginResponse.userEmail,
-        firstName: loginResponse.firstName,
-        lastName: loginResponse.lastName,
-        profileImagePath: loginResponse.profileImagePath,
-        isAdmin: loginResponse.isAdmin,
-        shouldChangePassword: loginResponse.shouldChangePassword,
+      return setSuccessLoginInfo(
+        accessToken: loginResponse.accessToken,
+        serverUrl: serverEndpoint,
+        isSavedLoginInfo: isSavedLoginInfo,
       );
       );
-
-      // Login Success - Set Access Token to API Client
-      _apiService.setAccessToken(loginResponse.accessToken);
-
-      if (isSavedLoginInfo) {
-        // Save login info to local storage
-        Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
-          savedLoginInfoKey,
-          HiveSavedLoginInfo(
-            email: email,
-            password: password,
-            isSaveLogin: true,
-            serverUrl: Hive.box(userInfoBox).get(serverEndpointKey),
-          ),
-        );
-      } else {
-        Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
-            .delete(savedLoginInfoKey);
-      }
     } catch (e) {
     } catch (e) {
       HapticFeedback.vibrate();
       HapticFeedback.vibrate();
       debugPrint("Error logging in $e");
       debugPrint("Error logging in $e");
       return false;
       return false;
     }
     }
-
-    // Register device info
-    try {
-      DeviceInfoResponseDto? deviceInfo =
-          await _apiService.deviceInfoApi.createDeviceInfo(
-        CreateDeviceInfoDto(
-          deviceId: state.deviceId,
-          deviceType: state.deviceType,
-        ),
-      );
-
-      if (deviceInfo == null) {
-        debugPrint('Device Info Response is null');
-        return false;
-      }
-
-      state = state.copyWith(deviceInfo: deviceInfo);
-    } catch (e) {
-      debugPrint("ERROR Register Device Info: $e");
-      return false;
-    }
-
-    return true;
   }
   }
 
 
   Future<bool> logout() async {
   Future<bool> logout() async {
-    Hive.box(userInfoBox).delete(accessTokenKey);
     state = state.copyWith(isAuthenticated: false);
     state = state.copyWith(isAuthenticated: false);
-    _assetCacheService.invalidate();
-    _albumCacheService.invalidate();
-    _sharedAlbumCacheService.invalidate();
+    await Future.wait([
+      Hive.box(userInfoBox).delete(accessTokenKey),
+      Hive.box(userInfoBox).delete(assetEtagKey),
+      _assetCacheService.invalidate(),
+      _albumCacheService.invalidate(),
+      _sharedAlbumCacheService.invalidate(),
+    ]);
 
 
     // Remove login info from local storage
     // Remove login info from local storage
     var loginInfo =
     var loginInfo =
@@ -173,7 +118,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
       loginInfo.password = "";
       loginInfo.password = "";
       loginInfo.isSaveLogin = false;
       loginInfo.isSaveLogin = false;
 
 
-      Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
+      await Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
         savedLoginInfoKey,
         savedLoginInfoKey,
         loginInfo,
         loginInfo,
       );
       );
@@ -215,6 +160,76 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
       return false;
       return false;
     }
     }
   }
   }
+
+  Future<bool> setSuccessLoginInfo({
+    required String accessToken,
+    required String serverUrl,
+    required bool isSavedLoginInfo,
+  }) async {
+    _apiService.setAccessToken(accessToken);
+    var userResponseDto = await _apiService.userApi.getMyUserInfo();
+
+    if (userResponseDto != null) {
+      var userInfoHiveBox = await Hive.openBox(userInfoBox);
+      var deviceInfo = await _deviceInfoService.getDeviceInfo();
+      userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
+      userInfoHiveBox.put(accessTokenKey, accessToken);
+      userInfoHiveBox.put(serverEndpointKey, serverUrl);
+
+      state = state.copyWith(
+        isAuthenticated: true,
+        userId: userResponseDto.id,
+        userEmail: userResponseDto.email,
+        firstName: userResponseDto.firstName,
+        lastName: userResponseDto.lastName,
+        profileImagePath: userResponseDto.profileImagePath,
+        isAdmin: userResponseDto.isAdmin,
+        shouldChangePassword: userResponseDto.shouldChangePassword,
+        deviceId: deviceInfo["deviceId"],
+        deviceType: deviceInfo["deviceType"],
+      );
+
+      if (isSavedLoginInfo) {
+        // Save login info to local storage
+        Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
+          savedLoginInfoKey,
+          HiveSavedLoginInfo(
+            email: "",
+            password: "",
+            isSaveLogin: true,
+            serverUrl: serverUrl,
+            accessToken: accessToken,
+          ),
+        );
+      } else {
+        Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
+            .delete(savedLoginInfoKey);
+      }
+    }
+
+    // Register device info
+    try {
+      DeviceInfoResponseDto? deviceInfo =
+          await _apiService.deviceInfoApi.createDeviceInfo(
+        CreateDeviceInfoDto(
+          deviceId: state.deviceId,
+          deviceType: state.deviceType,
+        ),
+      );
+
+      if (deviceInfo == null) {
+        debugPrint('Device Info Response is null');
+        return false;
+      }
+
+      state = state.copyWith(deviceInfo: deviceInfo);
+    } catch (e) {
+      debugPrint("ERROR Register Device Info: $e");
+      return false;
+    }
+
+    return true;
+  }
 }
 }
 
 
 final authenticationProvider =
 final authenticationProvider =

+ 6 - 0
mobile/lib/modules/login/providers/oauth.provider.dart

@@ -0,0 +1,6 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/login/services/oauth.service.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+
+final oAuthServiceProvider =
+    Provider((ref) => OAuthService(ref.watch(apiServiceProvider)));

+ 39 - 0
mobile/lib/modules/login/services/oauth.service.dart

@@ -0,0 +1,39 @@
+import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:openapi/api.dart';
+import 'package:flutter_web_auth/flutter_web_auth.dart';
+
+// Redirect URL = app.immich://
+
+class OAuthService {
+  final ApiService _apiService;
+  final callbackUrlScheme = 'app.immich';
+
+  OAuthService(this._apiService);
+
+  Future<OAuthConfigResponseDto?> getOAuthServerConfig(
+    String serverEndpoint,
+  ) async {
+    _apiService.setEndpoint(serverEndpoint);
+
+    return await _apiService.oAuthApi.generateConfig(
+      OAuthConfigDto(redirectUri: '$callbackUrlScheme:/'),
+    );
+  }
+
+  Future<LoginResponseDto?> oAuthLogin(String oauthUrl) async {
+    try {
+      var result = await FlutterWebAuth.authenticate(
+        url: oauthUrl,
+        callbackUrlScheme: callbackUrlScheme,
+      );
+
+      return await _apiService.oAuthApi.callback(
+        OAuthCallbackDto(
+          url: result,
+        ),
+      );
+    } catch (e) {
+      return null;
+    }
+  }
+}

+ 207 - 21
mobile/lib/modules/login/ui/login_form.dart

@@ -6,11 +6,14 @@ import 'package:hive/hive.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
 import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
+import 'package:immich_mobile/modules/login/providers/oauth.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:openapi/api.dart';
 
 
 class LoginForm extends HookConsumerWidget {
 class LoginForm extends HookConsumerWidget {
   const LoginForm({Key? key}) : super(key: key);
   const LoginForm({Key? key}) : super(key: key);
@@ -23,10 +26,47 @@ class LoginForm extends HookConsumerWidget {
         useTextEditingController.fromValue(TextEditingValue.empty);
         useTextEditingController.fromValue(TextEditingValue.empty);
     final serverEndpointController =
     final serverEndpointController =
         useTextEditingController(text: 'login_form_endpoint_hint'.tr());
         useTextEditingController(text: 'login_form_endpoint_hint'.tr());
+    final apiService = ref.watch(apiServiceProvider);
+    final serverEndpointFocusNode = useFocusNode();
     final isSaveLoginInfo = useState<bool>(false);
     final isSaveLoginInfo = useState<bool>(false);
+    final isLoading = useState<bool>(false);
+    final isOauthEnable = useState<bool>(false);
+    final oAuthButtonLabel = useState<String>('OAuth');
+
+    getServeLoginConfig() async {
+      if (!serverEndpointFocusNode.hasFocus) {
+        var urlText = serverEndpointController.text.trim();
+
+        try {
+          var endpointUrl = Uri.tryParse(urlText);
+
+          if (endpointUrl != null) {
+            isLoading.value = true;
+            apiService.setEndpoint(endpointUrl.toString());
+            var loginConfig = await apiService.oAuthApi.generateConfig(
+              OAuthConfigDto(redirectUri: endpointUrl.toString()),
+            );
+
+            if (loginConfig != null) {
+              isOauthEnable.value = loginConfig.enabled;
+              oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth';
+            } else {
+              isOauthEnable.value = false;
+            }
+
+            isLoading.value = false;
+          }
+        } catch (_) {
+          isLoading.value = false;
+          isOauthEnable.value = false;
+        }
+      }
+    }
 
 
     useEffect(
     useEffect(
       () {
       () {
+        serverEndpointFocusNode.addListener(getServeLoginConfig);
+
         var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
         var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
             .get(savedLoginInfoKey);
             .get(savedLoginInfoKey);
 
 
@@ -37,11 +77,19 @@ class LoginForm extends HookConsumerWidget {
           isSaveLoginInfo.value = loginInfo.isSaveLogin;
           isSaveLoginInfo.value = loginInfo.isSaveLogin;
         }
         }
 
 
+        getServeLoginConfig();
         return null;
         return null;
       },
       },
       [],
       [],
     );
     );
 
 
+    populateTestLoginInfo() {
+      usernameController.text = 'testuser@email.com';
+      passwordController.text = 'password';
+      serverEndpointController.text = 'http://10.1.15.216:2283/api';
+      isSaveLoginInfo.value = true;
+    }
+
     return Center(
     return Center(
       child: ConstrainedBox(
       child: ConstrainedBox(
         constraints: const BoxConstraints(maxWidth: 300),
         constraints: const BoxConstraints(maxWidth: 300),
@@ -51,10 +99,13 @@ class LoginForm extends HookConsumerWidget {
             runSpacing: 16,
             runSpacing: 16,
             alignment: WrapAlignment.center,
             alignment: WrapAlignment.center,
             children: [
             children: [
-              const Image(
-                image: AssetImage('assets/immich-logo-no-outline.png'),
-                width: 100,
-                filterQuality: FilterQuality.high,
+              GestureDetector(
+                onDoubleTap: () => populateTestLoginInfo(),
+                child: const Image(
+                  image: AssetImage('assets/immich-logo-no-outline.png'),
+                  width: 100,
+                  filterQuality: FilterQuality.high,
+                ),
               ),
               ),
               Text(
               Text(
                 'IMMICH',
                 'IMMICH',
@@ -67,7 +118,10 @@ class LoginForm extends HookConsumerWidget {
               ),
               ),
               EmailInput(controller: usernameController),
               EmailInput(controller: usernameController),
               PasswordInput(controller: passwordController),
               PasswordInput(controller: passwordController),
-              ServerEndpointInput(controller: serverEndpointController),
+              ServerEndpointInput(
+                controller: serverEndpointController,
+                focusNode: serverEndpointFocusNode,
+              ),
               CheckboxListTile(
               CheckboxListTile(
                 activeColor: Theme.of(context).primaryColor,
                 activeColor: Theme.of(context).primaryColor,
                 contentPadding: const EdgeInsets.symmetric(horizontal: 8),
                 contentPadding: const EdgeInsets.symmetric(horizontal: 8),
@@ -92,12 +146,52 @@ class LoginForm extends HookConsumerWidget {
                   }
                   }
                 },
                 },
               ),
               ),
-              LoginButton(
-                emailController: usernameController,
-                passwordController: passwordController,
-                serverEndpointController: serverEndpointController,
-                isSavedLoginInfo: isSaveLoginInfo.value,
-              ),
+              if (isLoading.value)
+                const SizedBox(
+                  width: 24,
+                  height: 24,
+                  child: CircularProgressIndicator(
+                    strokeWidth: 2,
+                  ),
+                ),
+              if (!isLoading.value)
+                Column(
+                  crossAxisAlignment: CrossAxisAlignment.stretch,
+                  mainAxisAlignment: MainAxisAlignment.center,
+                  children: [
+                    LoginButton(
+                      emailController: usernameController,
+                      passwordController: passwordController,
+                      serverEndpointController: serverEndpointController,
+                      isSavedLoginInfo: isSaveLoginInfo.value,
+                    ),
+                    if (isOauthEnable.value) ...[
+                      Padding(
+                        padding: const EdgeInsets.symmetric(
+                          horizontal: 16.0,
+                        ),
+                        child: Divider(
+                          color: Brightness.dark == Theme.of(context).brightness
+                              ? Colors.white
+                              : Colors.black,
+                        ),
+                      ),
+                      OAuthLoginButton(
+                        serverEndpointController: serverEndpointController,
+                        isSavedLoginInfo: isSaveLoginInfo.value,
+                        buttonLabel: oAuthButtonLabel.value,
+                        isLoading: isLoading,
+                        onLoginSuccess: () {
+                          isLoading.value = false;
+                          ref.watch(backupProvider.notifier).resumeBackup();
+                          AutoRouter.of(context).replace(
+                            const TabControllerRoute(),
+                          );
+                        },
+                      ),
+                    ],
+                  ],
+                )
             ],
             ],
           ),
           ),
         ),
         ),
@@ -108,9 +202,12 @@ class LoginForm extends HookConsumerWidget {
 
 
 class ServerEndpointInput extends StatelessWidget {
 class ServerEndpointInput extends StatelessWidget {
   final TextEditingController controller;
   final TextEditingController controller;
-
-  const ServerEndpointInput({Key? key, required this.controller})
-      : super(key: key);
+  final FocusNode focusNode;
+  const ServerEndpointInput({
+    Key? key,
+    required this.controller,
+    required this.focusNode,
+  }) : super(key: key);
 
 
   String? _validateInput(String? url) {
   String? _validateInput(String? url) {
     if (url?.startsWith(RegExp(r'https?://')) == true) {
     if (url?.startsWith(RegExp(r'https?://')) == true) {
@@ -131,6 +228,7 @@ class ServerEndpointInput extends StatelessWidget {
       ),
       ),
       validator: _validateInput,
       validator: _validateInput,
       autovalidateMode: AutovalidateMode.always,
       autovalidateMode: AutovalidateMode.always,
+      focusNode: focusNode,
     );
     );
   }
   }
 }
 }
@@ -200,13 +298,9 @@ class LoginButton extends ConsumerWidget {
 
 
   @override
   @override
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
-    return ElevatedButton(
+    return ElevatedButton.icon(
       style: ElevatedButton.styleFrom(
       style: ElevatedButton.styleFrom(
-        visualDensity: VisualDensity.standard,
-        backgroundColor: Theme.of(context).primaryColor,
-        foregroundColor: Colors.grey[50],
-        elevation: 2,
-        padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
+        padding: const EdgeInsets.symmetric(vertical: 12),
       ),
       ),
       onPressed: () async {
       onPressed: () async {
         // This will remove current cache asset state of previous user login.
         // This will remove current cache asset state of previous user login.
@@ -238,10 +332,102 @@ class LoginButton extends ConsumerWidget {
           );
           );
         }
         }
       },
       },
-      child: const Text(
+      icon: const Icon(Icons.login_rounded),
+      label: const Text(
         "login_form_button_text",
         "login_form_button_text",
         style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
         style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
       ).tr(),
       ).tr(),
     );
     );
   }
   }
 }
 }
+
+class OAuthLoginButton extends ConsumerWidget {
+  final TextEditingController serverEndpointController;
+  final bool isSavedLoginInfo;
+  final ValueNotifier<bool> isLoading;
+  final VoidCallback onLoginSuccess;
+  final String buttonLabel;
+
+  const OAuthLoginButton({
+    Key? key,
+    required this.serverEndpointController,
+    required this.isSavedLoginInfo,
+    required this.isLoading,
+    required this.onLoginSuccess,
+    required this.buttonLabel,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    var oAuthService = ref.watch(oAuthServiceProvider);
+
+    void performOAuthLogin() async {
+      ref.watch(assetProvider.notifier).clearAllAsset();
+      OAuthConfigResponseDto? oAuthServerConfig;
+
+      try {
+        oAuthServerConfig = await oAuthService
+            .getOAuthServerConfig(serverEndpointController.text);
+
+        isLoading.value = true;
+      } catch (e) {
+        ImmichToast.show(
+          context: context,
+          msg: "login_form_failed_get_oauth_server_config".tr(),
+          toastType: ToastType.error,
+        );
+        isLoading.value = false;
+        return;
+      }
+
+      if (oAuthServerConfig != null && oAuthServerConfig.enabled) {
+        var loginResponseDto =
+            await oAuthService.oAuthLogin(oAuthServerConfig.url!);
+
+        if (loginResponseDto != null) {
+          var isSuccess = await ref
+              .watch(authenticationProvider.notifier)
+              .setSuccessLoginInfo(
+                accessToken: loginResponseDto.accessToken,
+                isSavedLoginInfo: isSavedLoginInfo,
+                serverUrl: serverEndpointController.text,
+              );
+
+          if (isSuccess) {
+            isLoading.value = false;
+            onLoginSuccess();
+          } else {
+            ImmichToast.show(
+              context: context,
+              msg: "login_form_failed_login".tr(),
+              toastType: ToastType.error,
+            );
+          }
+        }
+
+        isLoading.value = false;
+      } else {
+        ImmichToast.show(
+          context: context,
+          msg: "login_form_failed_get_oauth_server_disable".tr(),
+          toastType: ToastType.info,
+        );
+        isLoading.value = false;
+        return;
+      }
+    }
+
+    return ElevatedButton.icon(
+      style: ElevatedButton.styleFrom(
+        backgroundColor: Theme.of(context).primaryColor.withAlpha(230),
+        padding: const EdgeInsets.symmetric(vertical: 12),
+      ),
+      onPressed: performOAuthLogin,
+      icon: const Icon(Icons.pin_rounded),
+      label: Text(
+        buttonLabel,
+        style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
+      ),
+    );
+  }
+}

+ 53 - 2
mobile/lib/modules/login/views/login_page.dart

@@ -1,14 +1,65 @@
+import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/login/ui/login_form.dart';
 import 'package:immich_mobile/modules/login/ui/login_form.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:package_info_plus/package_info_plus.dart';
 
 
 class LoginPage extends HookConsumerWidget {
 class LoginPage extends HookConsumerWidget {
   const LoginPage({Key? key}) : super(key: key);
   const LoginPage({Key? key}) : super(key: key);
 
 
   @override
   @override
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
-    return const Scaffold(
-      body: LoginForm(),
+    final appVersion = useState('0.0.0');
+
+    getAppInfo() async {
+      PackageInfo packageInfo = await PackageInfo.fromPlatform();
+      appVersion.value = packageInfo.version;
+    }
+
+    useEffect(
+      () {
+        getAppInfo();
+        return null;
+      },
+    );
+
+    return Scaffold(
+      body: const LoginForm(),
+      bottomNavigationBar: Padding(
+        padding: const EdgeInsets.only(bottom: 16.0),
+        child: SizedBox(
+          height: 50,
+          child: Row(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: [
+              Text(
+                'v${appVersion.value}',
+                style: const TextStyle(
+                  color: Colors.grey,
+                  fontWeight: FontWeight.bold,
+                  fontFamily: "Inconsolata",
+                ),
+              ),
+              const Text(' '),
+              GestureDetector(
+                child: Text(
+                  'Logs',
+                  style: TextStyle(
+                    color: Theme.of(context).primaryColor,
+                    fontWeight: FontWeight.bold,
+                    fontFamily: "Inconsolata",
+                  ),
+                ),
+                onTap: () {
+                  AutoRouter.of(context).push(const AppLogRoute());
+                },
+              ),
+            ],
+          ),
+        ),
+      ),
     );
     );
   }
   }
 }
 }

+ 7 - 7
mobile/lib/modules/search/views/search_page.dart

@@ -39,14 +39,14 @@ class SearchPage extends HookConsumerWidget {
       [],
       [],
     );
     );
 
 
-    _onSearchSubmitted(String searchTerm) async {
+    onSearchSubmitted(String searchTerm) async {
       searchFocusNode.unfocus();
       searchFocusNode.unfocus();
       ref.watch(searchPageStateProvider.notifier).disableSearch();
       ref.watch(searchPageStateProvider.notifier).disableSearch();
 
 
       AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
       AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
     }
     }
 
 
-    _buildPlaces() {
+    buildPlaces() {
       return curatedLocation.when(
       return curatedLocation.when(
         loading: () => SizedBox(
         loading: () => SizedBox(
           height: imageSize,
           height: imageSize,
@@ -97,7 +97,7 @@ class SearchPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildThings() {
+    buildThings() {
       return curatedObjects.when(
       return curatedObjects.when(
         loading: () => SizedBox(
         loading: () => SizedBox(
           height: imageSize,
           height: imageSize,
@@ -155,7 +155,7 @@ class SearchPage extends HookConsumerWidget {
     return Scaffold(
     return Scaffold(
       appBar: SearchBar(
       appBar: SearchBar(
         searchFocusNode: searchFocusNode,
         searchFocusNode: searchFocusNode,
-        onSubmitted: _onSearchSubmitted,
+        onSubmitted: onSearchSubmitted,
       ),
       ),
       body: GestureDetector(
       body: GestureDetector(
         onTap: () {
         onTap: () {
@@ -174,7 +174,7 @@ class SearchPage extends HookConsumerWidget {
                     style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
                     style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
                   ).tr(),
                   ).tr(),
                 ),
                 ),
-                _buildPlaces(),
+                buildPlaces(),
                 Padding(
                 Padding(
                   padding: const EdgeInsets.all(16.0),
                   padding: const EdgeInsets.all(16.0),
                   child: const Text(
                   child: const Text(
@@ -182,11 +182,11 @@ class SearchPage extends HookConsumerWidget {
                     style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
                     style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
                   ).tr(),
                   ).tr(),
                 ),
                 ),
-                _buildThings()
+                buildThings()
               ],
               ],
             ),
             ),
             if (isSearchEnabled)
             if (isSearchEnabled)
-              SearchSuggestionList(onSubmitted: _onSearchSubmitted),
+              SearchSuggestionList(onSubmitted: onSearchSubmitted),
           ],
           ],
         ),
         ),
       ),
       ),

+ 8 - 8
mobile/lib/modules/search/views/search_result_page.dart

@@ -38,7 +38,7 @@ class SearchResultPage extends HookConsumerWidget {
       [],
       [],
     );
     );
 
 
-    _onSearchSubmitted(String newSearchTerm) {
+    onSearchSubmitted(String newSearchTerm) {
       debugPrint("Re-Search with $newSearchTerm");
       debugPrint("Re-Search with $newSearchTerm");
       searchFocusNode?.unfocus();
       searchFocusNode?.unfocus();
       isNewSearch.value = false;
       isNewSearch.value = false;
@@ -46,7 +46,7 @@ class SearchResultPage extends HookConsumerWidget {
       ref.watch(searchResultPageProvider.notifier).search(newSearchTerm);
       ref.watch(searchResultPageProvider.notifier).search(newSearchTerm);
     }
     }
 
 
-    _buildTextField() {
+    buildTextField() {
       return TextField(
       return TextField(
         controller: searchTermController,
         controller: searchTermController,
         focusNode: searchFocusNode,
         focusNode: searchFocusNode,
@@ -60,7 +60,7 @@ class SearchResultPage extends HookConsumerWidget {
         onSubmitted: (searchTerm) {
         onSubmitted: (searchTerm) {
           if (searchTerm.isNotEmpty) {
           if (searchTerm.isNotEmpty) {
             searchTermController.clear();
             searchTermController.clear();
-            _onSearchSubmitted(searchTerm);
+            onSearchSubmitted(searchTerm);
           } else {
           } else {
             isNewSearch.value = false;
             isNewSearch.value = false;
           }
           }
@@ -80,7 +80,7 @@ class SearchResultPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildChip() {
+    buildChip() {
       return Chip(
       return Chip(
         label: Wrap(
         label: Wrap(
           spacing: 5,
           spacing: 5,
@@ -108,7 +108,7 @@ class SearchResultPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
-    _buildSearchResult() {
+    buildSearchResult() {
       var searchResultPageState = ref.watch(searchResultPageProvider);
       var searchResultPageState = ref.watch(searchResultPageProvider);
       var searchResultRenderList = ref.watch(searchRenderListProvider);
       var searchResultRenderList = ref.watch(searchRenderListProvider);
 
 
@@ -154,7 +154,7 @@ class SearchResultPage extends HookConsumerWidget {
             isNewSearch.value = true;
             isNewSearch.value = true;
             searchFocusNode?.requestFocus();
             searchFocusNode?.requestFocus();
           },
           },
-          child: isNewSearch.value ? _buildTextField() : _buildChip(),
+          child: isNewSearch.value ? buildTextField() : buildChip(),
         ),
         ),
         centerTitle: false,
         centerTitle: false,
       ),
       ),
@@ -168,9 +168,9 @@ class SearchResultPage extends HookConsumerWidget {
         },
         },
         child: Stack(
         child: Stack(
           children: [
           children: [
-            _buildSearchResult(),
+            buildSearchResult(),
             if (isNewSearch.value)
             if (isNewSearch.value)
-              SearchSuggestionList(onSubmitted: _onSearchSubmitted),
+              SearchSuggestionList(onSubmitted: onSearchSubmitted),
           ],
           ],
         ),
         ),
       ),
       ),

+ 0 - 1
mobile/lib/modules/settings/views/settings_page.dart

@@ -4,7 +4,6 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
 import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
-import 'package:immich_mobile/modules/settings/ui/experimental_settings/experimental_settings.dart';
 import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
 import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
 import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
 import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
 import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
 import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';

+ 16 - 11
mobile/lib/routing/router.dart

@@ -1,33 +1,34 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
+import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
+import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
+import 'package:immich_mobile/modules/album/views/create_album_page.dart';
 import 'package:immich_mobile/modules/album/views/library_page.dart';
 import 'package:immich_mobile/modules/album/views/library_page.dart';
+import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
+import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
+import 'package:immich_mobile/modules/album/views/sharing_page.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
+import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
+import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
 import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
 import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
 import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
 import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
+import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
 import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
 import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
+import 'package:immich_mobile/modules/home/views/home_page.dart';
 import 'package:immich_mobile/modules/login/views/change_password_page.dart';
 import 'package:immich_mobile/modules/login/views/change_password_page.dart';
 import 'package:immich_mobile/modules/login/views/login_page.dart';
 import 'package:immich_mobile/modules/login/views/login_page.dart';
-import 'package:immich_mobile/modules/home/views/home_page.dart';
 import 'package:immich_mobile/modules/search/views/search_page.dart';
 import 'package:immich_mobile/modules/search/views/search_page.dart';
 import 'package:immich_mobile/modules/search/views/search_result_page.dart';
 import 'package:immich_mobile/modules/search/views/search_result_page.dart';
-import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
-import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
-import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
-import 'package:immich_mobile/modules/album/views/create_album_page.dart';
-import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
-import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
-import 'package:immich_mobile/modules/album/views/sharing_page.dart';
 import 'package:immich_mobile/modules/settings/views/settings_page.dart';
 import 'package:immich_mobile/modules/settings/views/settings_page.dart';
 import 'package:immich_mobile/routing/auth_guard.dart';
 import 'package:immich_mobile/routing/auth_guard.dart';
-import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
-import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:immich_mobile/shared/views/app_log_page.dart';
 import 'package:immich_mobile/shared/views/splash_screen.dart';
 import 'package:immich_mobile/shared/views/splash_screen.dart';
 import 'package:immich_mobile/shared/views/tab_controller_page.dart';
 import 'package:immich_mobile/shared/views/tab_controller_page.dart';
-import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:photo_manager/photo_manager.dart';
 
 
@@ -80,6 +81,10 @@ part 'router.gr.dart';
       transitionsBuilder: TransitionsBuilders.slideBottom,
       transitionsBuilder: TransitionsBuilders.slideBottom,
     ),
     ),
     AutoRoute(page: SettingsPage, guards: [AuthGuard]),
     AutoRoute(page: SettingsPage, guards: [AuthGuard]),
+    CustomRoute(
+      page: AppLogPage,
+      transitionsBuilder: TransitionsBuilders.slideBottom,
+    ),
   ],
   ],
 )
 )
 class AppRouter extends _$AppRouter {
 class AppRouter extends _$AppRouter {

+ 18 - 1
mobile/lib/routing/router.gr.dart

@@ -142,6 +142,14 @@ class _$AppRouter extends RootStackRouter {
       return MaterialPageX<dynamic>(
       return MaterialPageX<dynamic>(
           routeData: routeData, child: const SettingsPage());
           routeData: routeData, child: const SettingsPage());
     },
     },
+    AppLogRoute.name: (routeData) {
+      return CustomPage<dynamic>(
+          routeData: routeData,
+          child: const AppLogPage(),
+          transitionsBuilder: TransitionsBuilders.slideBottom,
+          opaque: true,
+          barrierDismissible: false);
+    },
     HomeRoute.name: (routeData) {
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
       return MaterialPageX<dynamic>(
           routeData: routeData, child: const HomePage());
           routeData: routeData, child: const HomePage());
@@ -218,7 +226,8 @@ class _$AppRouter extends RootStackRouter {
         RouteConfig(FailedBackupStatusRoute.name,
         RouteConfig(FailedBackupStatusRoute.name,
             path: '/failed-backup-status-page', guards: [authGuard]),
             path: '/failed-backup-status-page', guards: [authGuard]),
         RouteConfig(SettingsRoute.name,
         RouteConfig(SettingsRoute.name,
-            path: '/settings-page', guards: [authGuard])
+            path: '/settings-page', guards: [authGuard]),
+        RouteConfig(AppLogRoute.name, path: '/app-log-page')
       ];
       ];
 }
 }
 
 
@@ -560,6 +569,14 @@ class SettingsRoute extends PageRouteInfo<void> {
   static const String name = 'SettingsRoute';
   static const String name = 'SettingsRoute';
 }
 }
 
 
+/// generated route for
+/// [AppLogPage]
+class AppLogRoute extends PageRouteInfo<void> {
+  const AppLogRoute() : super(AppLogRoute.name, path: '/app-log-page');
+
+  static const String name = 'AppLogRoute';
+}
+
 /// generated route for
 /// generated route for
 /// [HomePage]
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {
 class HomeRoute extends PageRouteInfo<void> {

+ 34 - 0
mobile/lib/shared/models/immich_logger_message.model.dart

@@ -0,0 +1,34 @@
+import 'package:hive/hive.dart';
+
+part 'immich_logger_message.model.g.dart';
+
+@HiveType(typeId: 3)
+class ImmichLoggerMessage {
+  @HiveField(0)
+  String message;
+
+  @HiveField(1, defaultValue: "INFO")
+  String level;
+
+  @HiveField(2)
+  DateTime createdAt;
+
+  @HiveField(3)
+  String? context1;
+
+  @HiveField(4)
+  String? context2;
+
+  ImmichLoggerMessage({
+    required this.message,
+    required this.level,
+    required this.createdAt,
+    required this.context1,
+    required this.context2,
+  });
+
+  @override
+  String toString() {
+    return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)';
+  }
+}

+ 53 - 0
mobile/lib/shared/models/immich_logger_message.model.g.dart

@@ -0,0 +1,53 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'immich_logger_message.model.dart';
+
+// **************************************************************************
+// TypeAdapterGenerator
+// **************************************************************************
+
+class ImmichLoggerMessageAdapter extends TypeAdapter<ImmichLoggerMessage> {
+  @override
+  final int typeId = 3;
+
+  @override
+  ImmichLoggerMessage read(BinaryReader reader) {
+    final numOfFields = reader.readByte();
+    final fields = <int, dynamic>{
+      for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
+    };
+    return ImmichLoggerMessage(
+      message: fields[0] as String,
+      level: fields[1] == null ? 'INFO' : fields[1] as String,
+      createdAt: fields[2] as DateTime,
+      context1: fields[3] as String?,
+      context2: fields[4] as String?,
+    );
+  }
+
+  @override
+  void write(BinaryWriter writer, ImmichLoggerMessage obj) {
+    writer
+      ..writeByte(5)
+      ..writeByte(0)
+      ..write(obj.message)
+      ..writeByte(1)
+      ..write(obj.level)
+      ..writeByte(2)
+      ..write(obj.createdAt)
+      ..writeByte(3)
+      ..write(obj.context1)
+      ..writeByte(4)
+      ..write(obj.context2);
+  }
+
+  @override
+  int get hashCode => typeId.hashCode;
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is ImmichLoggerMessageAdapter &&
+          runtimeType == other.runtimeType &&
+          typeId == other.typeId;
+}

+ 52 - 18
mobile/lib/shared/providers/asset.provider.dart

@@ -1,20 +1,22 @@
 import 'dart:collection';
 import 'dart:collection';
 
 
-import 'package:flutter/foundation.dart';
+import 'package:hive/hive.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
 import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
 import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/services/device_info.service.dart';
 import 'package:immich_mobile/shared/services/device_info.service.dart';
 import 'package:collection/collection.dart';
 import 'package:collection/collection.dart';
 import 'package:intl/intl.dart';
 import 'package:intl/intl.dart';
+import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:photo_manager/photo_manager.dart';
 
 
 class AssetNotifier extends StateNotifier<List<Asset>> {
 class AssetNotifier extends StateNotifier<List<Asset>> {
   final AssetService _assetService;
   final AssetService _assetService;
   final AssetCacheService _assetCacheService;
   final AssetCacheService _assetCacheService;
-
+  final log = Logger('AssetNotifier');
   final DeviceInfoService _deviceInfoService = DeviceInfoService();
   final DeviceInfoService _deviceInfoService = DeviceInfoService();
   bool _getAllAssetInProgress = false;
   bool _getAllAssetInProgress = false;
   bool _deleteInProgress = false;
   bool _deleteInProgress = false;
@@ -33,31 +35,61 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
     final stopwatch = Stopwatch();
     final stopwatch = Stopwatch();
     try {
     try {
       _getAllAssetInProgress = true;
       _getAllAssetInProgress = true;
-
       final bool isCacheValid = await _assetCacheService.isValid();
       final bool isCacheValid = await _assetCacheService.isValid();
+      stopwatch.start();
+      final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
+      final remoteTask = _assetService.getRemoteAssets(hasCache: isCacheValid);
       if (isCacheValid && state.isEmpty) {
       if (isCacheValid && state.isEmpty) {
-        stopwatch.start();
         state = await _assetCacheService.get();
         state = await _assetCacheService.get();
-        debugPrint(
-            "Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
+        log.info(
+          "Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms",
+        );
         stopwatch.reset();
         stopwatch.reset();
       }
       }
 
 
-      stopwatch.start();
-      var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid);
-      debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
+      int remoteBegin = state.indexWhere((a) => a.isRemote);
+      remoteBegin = remoteBegin == -1 ? state.length : remoteBegin;
+      final List<Asset> currentLocal = state.slice(0, remoteBegin);
+      List<Asset>? newRemote = await remoteTask;
+      List<Asset>? newLocal = await localTask;
+      log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
       stopwatch.reset();
       stopwatch.reset();
-
-      state = allAssets;
+      if (newRemote == null &&
+          (newLocal == null || currentLocal.equals(newLocal))) {
+        log.info("state is already up-to-date");
+        return;
+      }
+      newRemote ??= state.slice(remoteBegin);
+      newLocal ??= [];
+      state = _combineLocalAndRemoteAssets(local: newLocal, remote: newRemote);
+      log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
     } finally {
     } finally {
       _getAllAssetInProgress = false;
       _getAllAssetInProgress = false;
     }
     }
-    debugPrint("[getAllAsset] setting new asset state");
+    log.info("setting new asset state");
 
 
-    stopwatch.start();
-    _cacheState();
-    debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
     stopwatch.reset();
     stopwatch.reset();
+    _cacheState();
+    log.info("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
+  }
+
+  List<Asset> _combineLocalAndRemoteAssets({
+    required Iterable<Asset> local,
+    required List<Asset> remote,
+  }) {
+    final List<Asset> assets = [];
+    if (remote.isNotEmpty && local.isNotEmpty) {
+      final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
+      final Set<String> existingIds = remote
+          .where((e) => e.deviceId == deviceId)
+          .map((e) => e.deviceAssetId)
+          .toSet();
+      local = local.where((e) => !existingIds.contains(e.id));
+    }
+    assets.addAll(local);
+    // the order (first all local, then remote assets) is important!
+    assets.addAll(remote);
+    return assets;
   }
   }
 
 
   clearAllAsset() {
   clearAllAsset() {
@@ -123,8 +155,8 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
     if (local.isNotEmpty) {
     if (local.isNotEmpty) {
       try {
       try {
         return await PhotoManager.editor.deleteWithIds(local);
         return await PhotoManager.editor.deleteWithIds(local);
-      } catch (e) {
-        debugPrint("Delete asset from device failed: $e");
+      } catch (e, stack) {
+        log.severe("Failed to delete asset from device", e, stack);
       }
       }
     }
     }
     return [];
     return [];
@@ -145,7 +177,9 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
 
 
 final assetProvider = StateNotifierProvider<AssetNotifier, List<Asset>>((ref) {
 final assetProvider = StateNotifierProvider<AssetNotifier, List<Asset>>((ref) {
   return AssetNotifier(
   return AssetNotifier(
-      ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider));
+    ref.watch(assetServiceProvider),
+    ref.watch(assetCacheServiceProvider),
+  );
 });
 });
 
 
 final assetGroupByDateTimeProvider = StateProvider((ref) {
 final assetGroupByDateTimeProvider = StateProvider((ref) {

+ 7 - 8
mobile/lib/shared/providers/release_info.provider.dart

@@ -6,10 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:http/http.dart';
 import 'package:http/http.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
 import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
+import 'package:logging/logging.dart';
 
 
 class ReleaseInfoNotifier extends StateNotifier<String> {
 class ReleaseInfoNotifier extends StateNotifier<String> {
   ReleaseInfoNotifier() : super("");
   ReleaseInfoNotifier() : super("");
-
+  final log = Logger('ReleaseInfoNotifier');
   void checkGithubReleaseInfo() async {
   void checkGithubReleaseInfo() async {
     final Client client = Client();
     final Client client = Client();
     var box = Hive.box(hiveGithubReleaseInfoBox);
     var box = Hive.box(hiveGithubReleaseInfoBox);
@@ -17,19 +18,17 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
     try {
     try {
       String? localReleaseVersion = box.get(githubReleaseInfoKey);
       String? localReleaseVersion = box.get(githubReleaseInfoKey);
       final res = await client.get(
       final res = await client.get(
-          Uri.parse(
-            "https://api.github.com/repos/immich-app/immich/releases/latest",
-          ),
-          headers: {"Accept": "application/vnd.github.v3+json"});
+        Uri.parse(
+          "https://api.github.com/repos/immich-app/immich/releases/latest",
+        ),
+        headers: {"Accept": "application/vnd.github.v3+json"},
+      );
 
 
       if (res.statusCode == 200) {
       if (res.statusCode == 200) {
         final data = jsonDecode(res.body);
         final data = jsonDecode(res.body);
         String latestTagVersion = data["tag_name"];
         String latestTagVersion = data["tag_name"];
         state = latestTagVersion;
         state = latestTagVersion;
 
 
-        debugPrint("Local release version $localReleaseVersion");
-        debugPrint("Remote release veresion $latestTagVersion");
-
         if (localReleaseVersion == null && latestTagVersion.isNotEmpty) {
         if (localReleaseVersion == null && latestTagVersion.isNotEmpty) {
           VersionAnnouncementOverlayController.appLoader.show();
           VersionAnnouncementOverlayController.appLoader.show();
           return;
           return;

+ 24 - 23
mobile/lib/shared/providers/websocket.provider.dart

@@ -6,23 +6,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:socket_io_client/socket_io_client.dart';
 import 'package:socket_io_client/socket_io_client.dart';
 
 
-class WebscoketState {
+class WebsocketState {
   final Socket? socket;
   final Socket? socket;
   final bool isConnected;
   final bool isConnected;
 
 
-  WebscoketState({
+  WebsocketState({
     this.socket,
     this.socket,
     required this.isConnected,
     required this.isConnected,
   });
   });
 
 
-  WebscoketState copyWith({
+  WebsocketState copyWith({
     Socket? socket,
     Socket? socket,
     bool? isConnected,
     bool? isConnected,
   }) {
   }) {
-    return WebscoketState(
+    return WebsocketState(
       socket: socket ?? this.socket,
       socket: socket ?? this.socket,
       isConnected: isConnected ?? this.isConnected,
       isConnected: isConnected ?? this.isConnected,
     );
     );
@@ -30,13 +31,13 @@ class WebscoketState {
 
 
   @override
   @override
   String toString() =>
   String toString() =>
-      'WebscoketState(socket: $socket, isConnected: $isConnected)';
+      'WebsocketState(socket: $socket, isConnected: $isConnected)';
 
 
   @override
   @override
   bool operator ==(Object other) {
   bool operator ==(Object other) {
     if (identical(this, other)) return true;
     if (identical(this, other)) return true;
 
 
-    return other is WebscoketState &&
+    return other is WebsocketState &&
         other.socket == socket &&
         other.socket == socket &&
         other.isConnected == isConnected;
         other.isConnected == isConnected;
   }
   }
@@ -45,12 +46,11 @@ class WebscoketState {
   int get hashCode => socket.hashCode ^ isConnected.hashCode;
   int get hashCode => socket.hashCode ^ isConnected.hashCode;
 }
 }
 
 
-class WebsocketNotifier extends StateNotifier<WebscoketState> {
+class WebsocketNotifier extends StateNotifier<WebsocketState> {
   WebsocketNotifier(this.ref)
   WebsocketNotifier(this.ref)
-      : super(WebscoketState(socket: null, isConnected: false)) {
-    debugPrint("Init websocket instance");
-  }
+      : super(WebsocketState(socket: null, isConnected: false));
 
 
+  final log = Logger('WebsocketNotifier');
   final Ref ref;
   final Ref ref;
 
 
   connect() {
   connect() {
@@ -60,8 +60,8 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
       var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
       var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
       var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
       var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
       try {
       try {
-        debugPrint("[WEBSOCKET] Attempting to connect to ws");
-        // Configure socket transports must be sepecified
+        debugPrint("Attempting to connect to websocket");
+        // Configure socket transports must be specified
         Socket socket = io(
         Socket socket = io(
           endpoint.toString().replaceAll('/api', ''),
           endpoint.toString().replaceAll('/api', ''),
           OptionBuilder()
           OptionBuilder()
@@ -76,18 +76,18 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
         );
         );
 
 
         socket.onConnect((_) {
         socket.onConnect((_) {
-          debugPrint("[WEBSOCKET] Established Websocket Connection");
-          state = WebscoketState(isConnected: true, socket: socket);
+          debugPrint("Established Websocket Connection");
+          state = WebsocketState(isConnected: true, socket: socket);
         });
         });
 
 
         socket.onDisconnect((_) {
         socket.onDisconnect((_) {
-          debugPrint("[WEBSOCKET] Disconnect to Websocket Connection");
-          state = WebscoketState(isConnected: false, socket: null);
+          debugPrint("Disconnect to Websocket Connection");
+          state = WebsocketState(isConnected: false, socket: null);
         });
         });
 
 
         socket.on('error', (errorMessage) {
         socket.on('error', (errorMessage) {
-          debugPrint("Webcoket Error - $errorMessage");
-          state = WebscoketState(isConnected: false, socket: null);
+          log.severe("Websocket Error - $errorMessage");
+          state = WebsocketState(isConnected: false, socket: null);
         });
         });
 
 
         socket.on('on_upload_success', (data) {
         socket.on('on_upload_success', (data) {
@@ -105,21 +105,22 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
   }
   }
 
 
   disconnect() {
   disconnect() {
-    debugPrint("[WEBSOCKET] Attempting to disconnect");
+    debugPrint("Attempting to disconnect from websocket");
+
     var socket = state.socket?.disconnect();
     var socket = state.socket?.disconnect();
 
 
     if (socket?.disconnected == true) {
     if (socket?.disconnected == true) {
-      state = WebscoketState(isConnected: false, socket: null);
+      state = WebsocketState(isConnected: false, socket: null);
     }
     }
   }
   }
 
 
   stopListenToEvent(String eventName) {
   stopListenToEvent(String eventName) {
-    debugPrint("[Websocket] Stop listening to event $eventName");
+    debugPrint("Stop listening to event $eventName");
     state.socket?.off(eventName);
     state.socket?.off(eventName);
   }
   }
 
 
   listenUploadEvent() {
   listenUploadEvent() {
-    debugPrint("[Websocket] Start listening to event on_upload_success");
+    debugPrint("Start listening to event on_upload_success");
     state.socket?.on('on_upload_success', (data) {
     state.socket?.on('on_upload_success', (data) {
       var jsonString = jsonDecode(data.toString());
       var jsonString = jsonDecode(data.toString());
       AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
       AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
@@ -132,6 +133,6 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
 }
 }
 
 
 final websocketProvider =
 final websocketProvider =
-    StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {
+    StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) {
   return WebsocketNotifier(ref);
   return WebsocketNotifier(ref);
 });
 });

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

@@ -5,6 +5,7 @@ class ApiService {
 
 
   late UserApi userApi;
   late UserApi userApi;
   late AuthenticationApi authenticationApi;
   late AuthenticationApi authenticationApi;
+  late OAuthApi oAuthApi;
   late AlbumApi albumApi;
   late AlbumApi albumApi;
   late AssetApi assetApi;
   late AssetApi assetApi;
   late ServerInfoApi serverInfoApi;
   late ServerInfoApi serverInfoApi;
@@ -14,6 +15,7 @@ class ApiService {
     _apiClient = ApiClient(basePath: endpoint);
     _apiClient = ApiClient(basePath: endpoint);
     userApi = UserApi(_apiClient);
     userApi = UserApi(_apiClient);
     authenticationApi = AuthenticationApi(_apiClient);
     authenticationApi = AuthenticationApi(_apiClient);
+    oAuthApi = OAuthApi(_apiClient);
     albumApi = AlbumApi(_apiClient);
     albumApi = AlbumApi(_apiClient);
     assetApi = AssetApi(_apiClient);
     assetApi = AssetApi(_apiClient);
     serverInfoApi = ServerInfoApi(_apiClient);
     serverInfoApi = ServerInfoApi(_apiClient);

+ 1 - 0
mobile/lib/shared/services/cache.service.dart

@@ -1,3 +1,4 @@
+// ignore: depend_on_referenced_packages
 import 'package:flutter_cache_manager/flutter_cache_manager.dart';
 import 'package:flutter_cache_manager/flutter_cache_manager.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';

+ 95 - 0
mobile/lib/shared/services/immich_logger.service.dart

@@ -0,0 +1,95 @@
+import 'dart:io';
+
+import 'package:flutter/widgets.dart';
+import 'package:hive/hive.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
+import 'package:logging/logging.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:share_plus/share_plus.dart';
+
+/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
+/// The logs are written to a Hive box and onto console, using `debugPrint` method.
+///
+/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property
+/// in the class.
+///
+/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
+/// and generate a csv file.
+class ImmichLogger {
+  final maxLogEntries = 200;
+  final Box<ImmichLoggerMessage> _box = Hive.box(immichLoggerBox);
+
+  List<ImmichLoggerMessage> get messages =>
+      _box.values.toList().reversed.toList();
+
+  ImmichLogger() {
+    _removeOverflowMessages();
+  }
+
+  init() {
+    Logger.root.level = Level.INFO;
+    Logger.root.onRecord.listen(_writeLogToHiveBox);
+  }
+
+  _removeOverflowMessages() {
+    if (_box.length > maxLogEntries) {
+      var numberOfEntryToBeDeleted = _box.length - maxLogEntries;
+      for (var i = 0; i < numberOfEntryToBeDeleted; i++) {
+        _box.deleteAt(0);
+      }
+    }
+  }
+
+  _writeLogToHiveBox(LogRecord record) {
+    final Box<ImmichLoggerMessage> box = Hive.box(immichLoggerBox);
+    var formattedMessage = record.message;
+
+    debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
+    box.add(
+      ImmichLoggerMessage(
+        message: formattedMessage,
+        level: record.level.name,
+        createdAt: record.time,
+        context1: record.loggerName,
+        context2: record.stackTrace?.toString(),
+      ),
+    );
+  }
+
+  void clearLogs() {
+    _box.clear();
+  }
+
+  Future<void> shareLogs() async {
+    final tempDir = await getTemporaryDirectory();
+    final dateTime = DateTime.now().toIso8601String();
+    final filePath = '${tempDir.path}/Immich_log_$dateTime.csv';
+    final logFile = await File(filePath).create();
+    final io = logFile.openWrite();
+    try {
+      // Write header
+      io.write("created_at,level,context,message,stacktrace\n");
+
+      // Write messages
+      for (final m in messages) {
+        io.write(
+          '${m.createdAt},${m.level},"${m.context1 ?? ""}","${m.message}","${m.context2 ?? ""}"\n',
+        );
+      }
+    } finally {
+      await io.flush();
+      await io.close();
+    }
+
+    // Share file
+    await Share.shareFiles(
+      [filePath],
+      subject: "Immich logs $dateTime",
+      sharePositionOrigin: Rect.zero,
+    );
+
+    // Clean up temp file
+    await logFile.delete();
+  }
+}

+ 7 - 3
mobile/lib/shared/services/json_cache.dart

@@ -23,8 +23,12 @@ abstract class JsonCache<T> {
   }
   }
 
 
   Future<void> invalidate() async {
   Future<void> invalidate() async {
-    final file = await _getCacheFile();
-    await file.delete();
+    try {
+      final file = await _getCacheFile();
+      await file.delete();
+    } on FileSystemException {
+      // file is already deleted
+    }
   }
   }
 
 
   Future<void> putRawData(dynamic data) async {
   Future<void> putRawData(dynamic data) async {
@@ -46,4 +50,4 @@ abstract class JsonCache<T> {
 
 
   void put(T data);
   void put(T data);
   Future<T> get();
   Future<T> get();
-}
+}

+ 4 - 1
mobile/lib/shared/ui/immich_loading_indicator.dart

@@ -15,7 +15,10 @@ class ImmichLoadingIndicator extends StatelessWidget {
         borderRadius: BorderRadius.circular(10),
         borderRadius: BorderRadius.circular(10),
       ),
       ),
       padding: const EdgeInsets.all(15),
       padding: const EdgeInsets.all(15),
-      child: const CircularProgressIndicator(color: Colors.white),
+      child: const CircularProgressIndicator(
+        color: Colors.white,
+        strokeWidth: 2,
+      ),
     );
     );
   }
   }
 }
 }

+ 6 - 5
mobile/lib/shared/ui/immich_toast.dart

@@ -9,12 +9,13 @@ class ImmichToast {
     required String msg,
     required String msg,
     ToastType toastType = ToastType.info,
     ToastType toastType = ToastType.info,
     ToastGravity gravity = ToastGravity.TOP,
     ToastGravity gravity = ToastGravity.TOP,
+    int durationInSecond = 3,
   }) {
   }) {
     final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
     final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
     final fToast = FToast();
     final fToast = FToast();
     fToast.init(context);
     fToast.init(context);
 
 
-    Color _getColor(ToastType type, BuildContext context) {
+    Color getColor(ToastType type, BuildContext context) {
       switch (type) {
       switch (type) {
         case ToastType.info:
         case ToastType.info:
           return Theme.of(context).primaryColor;
           return Theme.of(context).primaryColor;
@@ -25,7 +26,7 @@ class ImmichToast {
       }
       }
     }
     }
 
 
-    Icon _getIcon(ToastType type) {
+    Icon getIcon(ToastType type) {
       switch (type) {
       switch (type) {
         case ToastType.info:
         case ToastType.info:
           return Icon(
           return Icon(
@@ -59,7 +60,7 @@ class ImmichToast {
         child: Row(
         child: Row(
           mainAxisSize: MainAxisSize.min,
           mainAxisSize: MainAxisSize.min,
           children: [
           children: [
-            _getIcon(toastType),
+            getIcon(toastType),
             const SizedBox(
             const SizedBox(
               width: 12.0,
               width: 12.0,
             ),
             ),
@@ -67,7 +68,7 @@ class ImmichToast {
               child: Text(
               child: Text(
                 msg,
                 msg,
                 style: TextStyle(
                 style: TextStyle(
-                  color: _getColor(toastType, context),
+                  color: getColor(toastType, context),
                   fontWeight: FontWeight.bold,
                   fontWeight: FontWeight.bold,
                   fontSize: 15,
                   fontSize: 15,
                 ),
                 ),
@@ -77,7 +78,7 @@ class ImmichToast {
         ),
         ),
       ),
       ),
       gravity: gravity,
       gravity: gravity,
-      toastDuration: const Duration(seconds: 2),
+      toastDuration: Duration(seconds: durationInSecond),
     );
     );
   }
   }
 }
 }

+ 165 - 0
mobile/lib/shared/views/app_log_page.dart

@@ -0,0 +1,165 @@
+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/shared/services/immich_logger.service.dart';
+import 'package:intl/intl.dart';
+
+class AppLogPage extends HookConsumerWidget {
+  const AppLogPage({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final immichLogger = ImmichLogger();
+    final logMessages = useState(immichLogger.messages);
+
+    Widget colorStatusIndicator(Color color) {
+      return Column(
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          Container(
+            width: 10,
+            height: 10,
+            decoration: BoxDecoration(
+              color: color,
+              shape: BoxShape.circle,
+            ),
+          ),
+        ],
+      );
+    }
+
+    Widget buildLeadingIcon(String level) {
+      switch (level) {
+        case "INFO":
+          return colorStatusIndicator(Theme.of(context).primaryColor);
+        case "SEVERE":
+          return colorStatusIndicator(Colors.redAccent);
+
+        case "WARNING":
+          return colorStatusIndicator(Colors.orangeAccent);
+        default:
+          return colorStatusIndicator(Colors.grey);
+      }
+    }
+
+    getTileColor(String level) {
+      switch (level) {
+        case "INFO":
+          return Colors.transparent;
+        case "SEVERE":
+          return Theme.of(context).brightness == Brightness.dark
+              ? Colors.redAccent.withOpacity(0.25)
+              : Colors.redAccent.withOpacity(0.075);
+        case "WARNING":
+          return Theme.of(context).brightness == Brightness.dark
+              ? Colors.orangeAccent.withOpacity(0.25)
+              : Colors.orangeAccent.withOpacity(0.075);
+        default:
+          return Theme.of(context).primaryColor.withOpacity(0.1);
+      }
+    }
+
+    return Scaffold(
+      appBar: AppBar(
+        title: Text(
+          "Logs - ${logMessages.value.length}",
+          style: const TextStyle(
+            fontWeight: FontWeight.bold,
+            fontSize: 16.0,
+          ),
+        ),
+        scrolledUnderElevation: 1,
+        elevation: 2,
+        actions: [
+          IconButton(
+            icon: Icon(
+              Icons.delete_outline_rounded,
+              color: Theme.of(context).primaryColor,
+              semanticLabel: "Clear logs",
+              size: 20.0,
+            ),
+            onPressed: () {
+              immichLogger.clearLogs();
+              logMessages.value = [];
+            },
+          ),
+          IconButton(
+            icon: Icon(
+              Icons.share_rounded,
+              color: Theme.of(context).primaryColor,
+              semanticLabel: "Share logs",
+              size: 20.0,
+            ),
+            onPressed: () {
+              immichLogger.shareLogs();
+            },
+          ),
+        ],
+        leading: IconButton(
+          onPressed: () {
+            AutoRouter.of(context).pop();
+          },
+          icon: const Icon(
+            Icons.arrow_back_ios_new_rounded,
+            size: 20.0,
+          ),
+        ),
+        centerTitle: true,
+      ),
+      body: ListView.separated(
+        separatorBuilder: (context, index) {
+          return Divider(
+            height: 0,
+            color: Theme.of(context).brightness == Brightness.dark
+                ? Colors.white70
+                : Colors.grey[600],
+          );
+        },
+        itemCount: logMessages.value.length,
+        itemBuilder: (context, index) {
+          var logMessage = logMessages.value[index];
+          return ListTile(
+            visualDensity: VisualDensity.compact,
+            dense: true,
+            tileColor: getTileColor(logMessage.level),
+            minLeadingWidth: 10,
+            title: Text.rich(
+              TextSpan(
+                children: [
+                  TextSpan(
+                    text: "#$index ",
+                    style: TextStyle(
+                      color: Theme.of(context).brightness == Brightness.dark
+                          ? Colors.white70
+                          : Colors.grey[600],
+                      fontSize: 14.0,
+                      fontWeight: FontWeight.bold,
+                    ),
+                  ),
+                  TextSpan(
+                    text: logMessage.message,
+                    style: const TextStyle(
+                      fontSize: 14.0,
+                    ),
+                  ),
+                ],
+              ),
+              style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
+            ),
+            subtitle: Text(
+              "[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}",
+              style: TextStyle(
+                fontSize: 12.0,
+                color: Colors.grey[600],
+              ),
+            ),
+            leading: buildLeadingIcon(logMessage.level),
+          );
+        },
+      ),
+    );
+  }
+}

+ 22 - 12
mobile/lib/shared/views/splash_screen.dart

@@ -8,29 +8,39 @@ import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
 import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
 
 
 class SplashScreenPage extends HookConsumerWidget {
 class SplashScreenPage extends HookConsumerWidget {
   const SplashScreenPage({Key? key}) : super(key: key);
   const SplashScreenPage({Key? key}) : super(key: key);
 
 
   @override
   @override
   Widget build(BuildContext context, WidgetRef ref) {
   Widget build(BuildContext context, WidgetRef ref) {
+    final apiService = ref.watch(apiServiceProvider);
     HiveSavedLoginInfo? loginInfo =
     HiveSavedLoginInfo? loginInfo =
         Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
         Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
 
 
     void performLoggingIn() async {
     void performLoggingIn() async {
-      var isAuthenticated =
-          await ref.read(authenticationProvider.notifier).login(
-                loginInfo!.email,
-                loginInfo.password,
-                loginInfo.serverUrl,
-                true,
-              );
+      try {
+        if (loginInfo != null) {
+          // Make sure API service is initialized
+          apiService.setEndpoint(loginInfo.serverUrl);
 
 
-      if (isAuthenticated) {
-        // Resume backup (if enable) then navigate
-        ref.watch(backupProvider.notifier).resumeBackup();
-        AutoRouter.of(context).replace(const TabControllerRoute());
-      } else {
+          var isSuccess = await ref
+              .read(authenticationProvider.notifier)
+              .setSuccessLoginInfo(
+                accessToken: loginInfo.accessToken,
+                isSavedLoginInfo: true,
+                serverUrl: loginInfo.serverUrl,
+              );
+          if (isSuccess) {
+            // Resume backup (if enable) then navigate
+            ref.watch(backupProvider.notifier).resumeBackup();
+            AutoRouter.of(context).replace(const TabControllerRoute());
+          } else {
+            AutoRouter.of(context).replace(const LoginRoute());
+          }
+        }
+      } catch (_) {
         AutoRouter.of(context).replace(const LoginRoute());
         AutoRouter.of(context).replace(const LoginRoute());
       }
       }
     }
     }

+ 15 - 13
mobile/lib/utils/bytes_units.dart

@@ -1,15 +1,17 @@
-
 String formatBytes(int bytes) {
 String formatBytes(int bytes) {
-  if (bytes < 1000) {
-    return "$bytes B";
-  } else if (bytes < 1000000) {
-    final kb = (bytes / 1000).toStringAsFixed(1);
-    return "$kb kB";
-  } else if (bytes < 1000000000) {
-    final mb = (bytes / 1000000).toStringAsFixed(1);
-    return "$mb MB";
-  } else {
-    final gb = (bytes / 1000000000).toStringAsFixed(1);
-    return "$gb GB";
+  const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
+
+  int magnitude = 0;
+  double remainder = bytes.toDouble();
+  while (remainder >= 1024) {
+    if (magnitude + 1 < units.length) {
+      magnitude++;
+      remainder /= 1024;
+    }
+    else {
+      break;
+    }
   }
   }
-}
+
+  return "${remainder.toStringAsFixed(magnitude == 0 ? 0 : 1)} ${units[magnitude]}";
+}

+ 2 - 0
mobile/lib/utils/immich_cache_info_repository.dart

@@ -1,3 +1,5 @@
+// ignore_for_file: depend_on_referenced_packages, implementation_imports
+
 import 'dart:io';
 import 'dart:io';
 import 'dart:math';
 import 'dart:math';
 
 

+ 53 - 0
mobile/lib/utils/openapi_extensions.dart

@@ -0,0 +1,53 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:http/http.dart';
+import 'package:openapi/api.dart';
+
+import 'tuple.dart';
+
+/// Extension methods to retrieve ETag together with the API call
+extension WithETag on AssetApi {
+  /// Get all AssetEntity belong to the user
+  ///
+  /// Parameters:
+  ///
+  /// * [String] eTag:
+  ///   ETag of data already cached on the client
+  Future<Pair<List<AssetResponseDto>, String?>?> getAllAssetsWithETag({
+    String? eTag,
+  }) async {
+    final response = await getAllAssetsWithHttpInfo(
+      ifNoneMatch: eTag,
+    );
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty &&
+        response.statusCode != HttpStatus.noContent) {
+      final responseBody = await _decodeBodyBytes(response);
+      final etag = response.headers[HttpHeaders.etagHeader];
+      final data = (await apiClient.deserializeAsync(
+              responseBody, 'List<AssetResponseDto>') as List)
+          .cast<AssetResponseDto>()
+          .toList();
+      return Pair(data, etag);
+    }
+    return null;
+  }
+}
+
+/// Returns the decoded body as UTF-8 if the given headers indicate an 'application/json'
+/// content type. Otherwise, returns the decoded body as decoded by dart:http package.
+Future<String> _decodeBodyBytes(Response response) async {
+  final contentType = response.headers['content-type'];
+  return contentType != null &&
+          contentType.toLowerCase().startsWith('application/json')
+      ? response.bodyBytes.isEmpty
+          ? ''
+          : utf8.decode(response.bodyBytes)
+      : response.body;
+}

+ 8 - 0
mobile/lib/utils/tuple.dart

@@ -0,0 +1,8 @@
+/// An immutable pair or 2-tuple
+/// TODO replace with Record once Dart 2.19 is available
+class Pair<T1, T2> {
+  final T1 first;
+  final T2 second;
+
+  const Pair(this.first, this.second);
+}

+ 7 - 3
mobile/openapi/doc/AssetApi.md

@@ -274,7 +274,7 @@ Name | Type | Description  | Notes
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
 # **getAllAssets**
 # **getAllAssets**
-> List<AssetResponseDto> getAllAssets()
+> List<AssetResponseDto> getAllAssets(ifNoneMatch)
 
 
 
 
 
 
@@ -291,9 +291,10 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 
 final api_instance = AssetApi();
 final api_instance = AssetApi();
+final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client
 
 
 try {
 try {
-    final result = api_instance.getAllAssets();
+    final result = api_instance.getAllAssets(ifNoneMatch);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
     print('Exception when calling AssetApi->getAllAssets: $e\n');
     print('Exception when calling AssetApi->getAllAssets: $e\n');
@@ -301,7 +302,10 @@ try {
 ```
 ```
 
 
 ### Parameters
 ### Parameters
-This endpoint does not need any parameter.
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **ifNoneMatch** | **String**| ETag of data already cached on the client | [optional] 
 
 
 ### Return type
 ### Return type
 
 

+ 2 - 2
mobile/openapi/doc/AssetResponseDto.md

@@ -21,10 +21,10 @@ Name | Type | Description | Notes
 **mimeType** | **String** |  | 
 **mimeType** | **String** |  | 
 **duration** | **String** |  | 
 **duration** | **String** |  | 
 **webpPath** | **String** |  | 
 **webpPath** | **String** |  | 
-**encodedVideoPath** | **String** |  | 
+**encodedVideoPath** | **String** |  | [optional] 
 **exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) |  | [optional] 
 **exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) |  | [optional] 
 **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional] 
 **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional] 
-**livePhotoVideoId** | **String** |  | 
+**livePhotoVideoId** | **String** |  | [optional] 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 0 - 1
mobile/openapi/doc/UsageByUserDto.md

@@ -9,7 +9,6 @@ import 'package:openapi/api.dart';
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
 **userId** | **String** |  | 
 **userId** | **String** |  | 
-**objects** | **int** |  | 
 **videos** | **int** |  | 
 **videos** | **int** |  | 
 **photos** | **int** |  | 
 **photos** | **int** |  | 
 **usageRaw** | **int** |  | 
 **usageRaw** | **int** |  | 

+ 1 - 1
mobile/openapi/doc/UserResponseDto.md

@@ -16,7 +16,7 @@ Name | Type | Description | Notes
 **profileImagePath** | **String** |  | 
 **profileImagePath** | **String** |  | 
 **shouldChangePassword** | **bool** |  | 
 **shouldChangePassword** | **bool** |  | 
 **isAdmin** | **bool** |  | 
 **isAdmin** | **bool** |  | 
-**deletedAt** | [**DateTime**](DateTime.md) |  | 
+**deletedAt** | [**DateTime**](DateTime.md) |  | [optional] 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 17 - 3
mobile/openapi/lib/api/asset_api.dart

@@ -297,7 +297,12 @@ class AssetApi {
   /// Get all AssetEntity belong to the user
   /// Get all AssetEntity belong to the user
   ///
   ///
   /// Note: This method returns the HTTP [Response].
   /// Note: This method returns the HTTP [Response].
-  Future<Response> getAllAssetsWithHttpInfo() async {
+  ///
+  /// Parameters:
+  ///
+  /// * [String] ifNoneMatch:
+  ///   ETag of data already cached on the client
+  Future<Response> getAllAssetsWithHttpInfo({ String? ifNoneMatch, }) async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations
     final path = r'/asset';
     final path = r'/asset';
 
 
@@ -308,6 +313,10 @@ class AssetApi {
     final headerParams = <String, String>{};
     final headerParams = <String, String>{};
     final formParams = <String, String>{};
     final formParams = <String, String>{};
 
 
+    if (ifNoneMatch != null) {
+      headerParams[r'if-none-match'] = parameterToString(ifNoneMatch);
+    }
+
     const contentTypes = <String>[];
     const contentTypes = <String>[];
 
 
 
 
@@ -325,8 +334,13 @@ class AssetApi {
   /// 
   /// 
   ///
   ///
   /// Get all AssetEntity belong to the user
   /// Get all AssetEntity belong to the user
-  Future<List<AssetResponseDto>?> getAllAssets() async {
-    final response = await getAllAssetsWithHttpInfo();
+  ///
+  /// Parameters:
+  ///
+  /// * [String] ifNoneMatch:
+  ///   ETag of data already cached on the client
+  Future<List<AssetResponseDto>?> getAllAssets({ String? ifNoneMatch, }) async {
+    final response = await getAllAssetsWithHttpInfo( ifNoneMatch: ifNoneMatch, );
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
     }

+ 60 - 73
mobile/openapi/lib/model/asset_response_dto.dart

@@ -26,10 +26,10 @@ class AssetResponseDto {
     required this.mimeType,
     required this.mimeType,
     required this.duration,
     required this.duration,
     required this.webpPath,
     required this.webpPath,
-    required this.encodedVideoPath,
+    this.encodedVideoPath,
     this.exifInfo,
     this.exifInfo,
     this.smartInfo,
     this.smartInfo,
-    required this.livePhotoVideoId,
+    this.livePhotoVideoId,
   });
   });
 
 
   AssetTypeEnum type;
   AssetTypeEnum type;
@@ -79,74 +79,71 @@ class AssetResponseDto {
   String? livePhotoVideoId;
   String? livePhotoVideoId;
 
 
   @override
   @override
-  bool operator ==(Object other) =>
-      identical(this, other) ||
-      other is AssetResponseDto &&
-          other.type == type &&
-          other.id == id &&
-          other.deviceAssetId == deviceAssetId &&
-          other.ownerId == ownerId &&
-          other.deviceId == deviceId &&
-          other.originalPath == originalPath &&
-          other.resizePath == resizePath &&
-          other.createdAt == createdAt &&
-          other.modifiedAt == modifiedAt &&
-          other.isFavorite == isFavorite &&
-          other.mimeType == mimeType &&
-          other.duration == duration &&
-          other.webpPath == webpPath &&
-          other.encodedVideoPath == encodedVideoPath &&
-          other.exifInfo == exifInfo &&
-          other.smartInfo == smartInfo &&
-          other.livePhotoVideoId == livePhotoVideoId;
+  bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
+     other.type == type &&
+     other.id == id &&
+     other.deviceAssetId == deviceAssetId &&
+     other.ownerId == ownerId &&
+     other.deviceId == deviceId &&
+     other.originalPath == originalPath &&
+     other.resizePath == resizePath &&
+     other.createdAt == createdAt &&
+     other.modifiedAt == modifiedAt &&
+     other.isFavorite == isFavorite &&
+     other.mimeType == mimeType &&
+     other.duration == duration &&
+     other.webpPath == webpPath &&
+     other.encodedVideoPath == encodedVideoPath &&
+     other.exifInfo == exifInfo &&
+     other.smartInfo == smartInfo &&
+     other.livePhotoVideoId == livePhotoVideoId;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
-      // ignore: unnecessary_parenthesis
-      (type.hashCode) +
-      (id.hashCode) +
-      (deviceAssetId.hashCode) +
-      (ownerId.hashCode) +
-      (deviceId.hashCode) +
-      (originalPath.hashCode) +
-      (resizePath == null ? 0 : resizePath!.hashCode) +
-      (createdAt.hashCode) +
-      (modifiedAt.hashCode) +
-      (isFavorite.hashCode) +
-      (mimeType == null ? 0 : mimeType!.hashCode) +
-      (duration.hashCode) +
-      (webpPath == null ? 0 : webpPath!.hashCode) +
-      (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
-      (exifInfo == null ? 0 : exifInfo!.hashCode) +
-      (smartInfo == null ? 0 : smartInfo!.hashCode) +
-      (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
+    // ignore: unnecessary_parenthesis
+    (type.hashCode) +
+    (id.hashCode) +
+    (deviceAssetId.hashCode) +
+    (ownerId.hashCode) +
+    (deviceId.hashCode) +
+    (originalPath.hashCode) +
+    (resizePath == null ? 0 : resizePath!.hashCode) +
+    (createdAt.hashCode) +
+    (modifiedAt.hashCode) +
+    (isFavorite.hashCode) +
+    (mimeType == null ? 0 : mimeType!.hashCode) +
+    (duration.hashCode) +
+    (webpPath == null ? 0 : webpPath!.hashCode) +
+    (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
+    (exifInfo == null ? 0 : exifInfo!.hashCode) +
+    (smartInfo == null ? 0 : smartInfo!.hashCode) +
+    (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
 
 
   @override
   @override
-  String toString() =>
-      'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId]';
+  String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
     final _json = <String, dynamic>{};
-    _json[r'type'] = type;
-    _json[r'id'] = id;
-    _json[r'deviceAssetId'] = deviceAssetId;
-    _json[r'ownerId'] = ownerId;
-    _json[r'deviceId'] = deviceId;
-    _json[r'originalPath'] = originalPath;
+      _json[r'type'] = type;
+      _json[r'id'] = id;
+      _json[r'deviceAssetId'] = deviceAssetId;
+      _json[r'ownerId'] = ownerId;
+      _json[r'deviceId'] = deviceId;
+      _json[r'originalPath'] = originalPath;
     if (resizePath != null) {
     if (resizePath != null) {
       _json[r'resizePath'] = resizePath;
       _json[r'resizePath'] = resizePath;
     } else {
     } else {
       _json[r'resizePath'] = null;
       _json[r'resizePath'] = null;
     }
     }
-    _json[r'createdAt'] = createdAt;
-    _json[r'modifiedAt'] = modifiedAt;
-    _json[r'isFavorite'] = isFavorite;
+      _json[r'createdAt'] = createdAt;
+      _json[r'modifiedAt'] = modifiedAt;
+      _json[r'isFavorite'] = isFavorite;
     if (mimeType != null) {
     if (mimeType != null) {
       _json[r'mimeType'] = mimeType;
       _json[r'mimeType'] = mimeType;
     } else {
     } else {
       _json[r'mimeType'] = null;
       _json[r'mimeType'] = null;
     }
     }
-    _json[r'duration'] = duration;
+      _json[r'duration'] = duration;
     if (webpPath != null) {
     if (webpPath != null) {
       _json[r'webpPath'] = webpPath;
       _json[r'webpPath'] = webpPath;
     } else {
     } else {
@@ -185,13 +182,13 @@ class AssetResponseDto {
       // Ensure that the map contains the required keys.
       // Ensure that the map contains the required keys.
       // Note 1: the values aren't checked for validity beyond being non-null.
       // Note 1: the values aren't checked for validity beyond being non-null.
       // Note 2: this code is stripped in release mode!
       // 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;
-      // }());
+      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(
       return AssetResponseDto(
         type: AssetTypeEnum.fromJson(json[r'type'])!,
         type: AssetTypeEnum.fromJson(json[r'type'])!,
@@ -216,10 +213,7 @@ class AssetResponseDto {
     return null;
     return null;
   }
   }
 
 
-  static List<AssetResponseDto>? listFromJson(
-    dynamic json, {
-    bool growable = false,
-  }) {
+  static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
     final result = <AssetResponseDto>[];
     final result = <AssetResponseDto>[];
     if (json is List && json.isNotEmpty) {
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
       for (final row in json) {
@@ -247,18 +241,12 @@ class AssetResponseDto {
   }
   }
 
 
   // maps a json object with a list of AssetResponseDto-objects as value to a dart map
   // maps a json object with a list of AssetResponseDto-objects as value to a dart map
-  static Map<String, List<AssetResponseDto>> mapListFromJson(
-    dynamic json, {
-    bool growable = false,
-  }) {
+  static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
     final map = <String, List<AssetResponseDto>>{};
     final map = <String, List<AssetResponseDto>>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        final value = AssetResponseDto.listFromJson(
-          entry.value,
-          growable: growable,
-        );
+        final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
         if (value != null) {
         if (value != null) {
           map[entry.key] = value;
           map[entry.key] = value;
         }
         }
@@ -282,7 +270,6 @@ class AssetResponseDto {
     'mimeType',
     'mimeType',
     'duration',
     'duration',
     'webpPath',
     'webpPath',
-    'encodedVideoPath',
-    'livePhotoVideoId',
   };
   };
 }
 }
+

+ 1 - 9
mobile/openapi/lib/model/usage_by_user_dto.dart

@@ -14,7 +14,6 @@ class UsageByUserDto {
   /// Returns a new [UsageByUserDto] instance.
   /// Returns a new [UsageByUserDto] instance.
   UsageByUserDto({
   UsageByUserDto({
     required this.userId,
     required this.userId,
-    required this.objects,
     required this.videos,
     required this.videos,
     required this.photos,
     required this.photos,
     required this.usageRaw,
     required this.usageRaw,
@@ -23,8 +22,6 @@ class UsageByUserDto {
 
 
   String userId;
   String userId;
 
 
-  int objects;
-
   int videos;
   int videos;
 
 
   int photos;
   int photos;
@@ -36,7 +33,6 @@ class UsageByUserDto {
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is UsageByUserDto &&
   bool operator ==(Object other) => identical(this, other) || other is UsageByUserDto &&
      other.userId == userId &&
      other.userId == userId &&
-     other.objects == objects &&
      other.videos == videos &&
      other.videos == videos &&
      other.photos == photos &&
      other.photos == photos &&
      other.usageRaw == usageRaw &&
      other.usageRaw == usageRaw &&
@@ -46,19 +42,17 @@ class UsageByUserDto {
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
     (userId.hashCode) +
     (userId.hashCode) +
-    (objects.hashCode) +
     (videos.hashCode) +
     (videos.hashCode) +
     (photos.hashCode) +
     (photos.hashCode) +
     (usageRaw.hashCode) +
     (usageRaw.hashCode) +
     (usage.hashCode);
     (usage.hashCode);
 
 
   @override
   @override
-  String toString() => 'UsageByUserDto[userId=$userId, objects=$objects, videos=$videos, photos=$photos, usageRaw=$usageRaw, usage=$usage]';
+  String toString() => 'UsageByUserDto[userId=$userId, videos=$videos, photos=$photos, usageRaw=$usageRaw, usage=$usage]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
     final _json = <String, dynamic>{};
       _json[r'userId'] = userId;
       _json[r'userId'] = userId;
-      _json[r'objects'] = objects;
       _json[r'videos'] = videos;
       _json[r'videos'] = videos;
       _json[r'photos'] = photos;
       _json[r'photos'] = photos;
       _json[r'usageRaw'] = usageRaw;
       _json[r'usageRaw'] = usageRaw;
@@ -86,7 +80,6 @@ class UsageByUserDto {
 
 
       return UsageByUserDto(
       return UsageByUserDto(
         userId: mapValueOfType<String>(json, r'userId')!,
         userId: mapValueOfType<String>(json, r'userId')!,
-        objects: mapValueOfType<int>(json, r'objects')!,
         videos: mapValueOfType<int>(json, r'videos')!,
         videos: mapValueOfType<int>(json, r'videos')!,
         photos: mapValueOfType<int>(json, r'photos')!,
         photos: mapValueOfType<int>(json, r'photos')!,
         usageRaw: mapValueOfType<int>(json, r'usageRaw')!,
         usageRaw: mapValueOfType<int>(json, r'usageRaw')!,
@@ -141,7 +134,6 @@ class UsageByUserDto {
   /// The list of required keys that must be present in a JSON.
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
   static const requiredKeys = <String>{
     'userId',
     'userId',
-    'objects',
     'videos',
     'videos',
     'photos',
     'photos',
     'usageRaw',
     'usageRaw',

+ 7 - 2
mobile/openapi/lib/model/user_response_dto.dart

@@ -21,7 +21,7 @@ class UserResponseDto {
     required this.profileImagePath,
     required this.profileImagePath,
     required this.shouldChangePassword,
     required this.shouldChangePassword,
     required this.isAdmin,
     required this.isAdmin,
-    required this.deletedAt,
+    this.deletedAt,
   });
   });
 
 
   String id;
   String id;
@@ -40,6 +40,12 @@ class UserResponseDto {
 
 
   bool isAdmin;
   bool isAdmin;
 
 
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
   DateTime? deletedAt;
   DateTime? deletedAt;
 
 
   @override
   @override
@@ -173,7 +179,6 @@ class UserResponseDto {
     'profileImagePath',
     'profileImagePath',
     'shouldChangePassword',
     'shouldChangePassword',
     'isAdmin',
     'isAdmin',
-    'deletedAt',
   };
   };
 }
 }
 
 

+ 17 - 10
mobile/pubspec.lock

@@ -266,7 +266,7 @@ packages:
       name: ffi
       name: ffi
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
-    version: "1.2.1"
+    version: "2.0.1"
   file:
   file:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -366,6 +366,13 @@ packages:
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
     version: "2.0.0"
     version: "2.0.0"
+  flutter_web_auth:
+    dependency: "direct main"
+    description:
+      name: flutter_web_auth
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.5.0"
   flutter_web_plugins:
   flutter_web_plugins:
     dependency: transitive
     dependency: transitive
     description: flutter
     description: flutter
@@ -547,12 +554,12 @@ packages:
     source: hosted
     source: hosted
     version: "1.0.1"
     version: "1.0.1"
   logging:
   logging:
-    dependency: transitive
+    dependency: "direct main"
     description:
     description:
       name: logging
       name: logging
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
-    version: "1.0.2"
+    version: "1.1.0"
   matcher:
   matcher:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -622,7 +629,7 @@ packages:
       name: package_info_plus
       name: package_info_plus
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
-    version: "1.4.2"
+    version: "1.4.3+1"
   package_info_plus_linux:
   package_info_plus_linux:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -657,7 +664,7 @@ packages:
       name: package_info_plus_windows
       name: package_info_plus_windows
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
-    version: "1.0.5"
+    version: "2.1.0"
   path:
   path:
     dependency: "direct main"
     dependency: "direct main"
     description:
     description:
@@ -692,7 +699,7 @@ packages:
       name: path_provider_linux
       name: path_provider_linux
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
-    version: "2.1.6"
+    version: "2.1.7"
   path_provider_macos:
   path_provider_macos:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -713,7 +720,7 @@ packages:
       name: path_provider_windows
       name: path_provider_windows
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
-    version: "2.0.6"
+    version: "2.1.3"
   pedantic:
   pedantic:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -991,14 +998,14 @@ packages:
       name: sqflite
       name: sqflite
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
-    version: "2.0.2+1"
+    version: "2.2.0+3"
   sqflite_common:
   sqflite_common:
     dependency: transitive
     dependency: transitive
     description:
     description:
       name: sqflite_common
       name: sqflite_common
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
-    version: "2.2.1+1"
+    version: "2.4.0+2"
   stack_trace:
   stack_trace:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -1250,7 +1257,7 @@ packages:
       name: win32
       name: win32
       url: "https://pub.dartlang.org"
       url: "https://pub.dartlang.org"
     source: hosted
     source: hosted
-    version: "2.5.2"
+    version: "2.7.0"
   wkt_parser:
   wkt_parser:
     dependency: transitive
     dependency: transitive
     description:
     description:

+ 6 - 2
mobile/pubspec.yaml

@@ -2,7 +2,7 @@ name: immich_mobile
 description: Immich - selfhosted backup media file on mobile phone
 description: Immich - selfhosted backup media file on mobile phone
 
 
 publish_to: "none"
 publish_to: "none"
-version: 1.35.0+54
+version: 1.37.0+58
 
 
 environment:
 environment:
   sdk: ">=2.17.0 <3.0.0"
   sdk: ">=2.17.0 <3.0.0"
@@ -40,12 +40,14 @@ dependencies:
   latlong2: ^0.8.1
   latlong2: ^0.8.1
   collection: ^1.16.0
   collection: ^1.16.0
   http_parser: ^4.0.1
   http_parser: ^4.0.1
+  flutter_web_auth: ^0.5.0
 
 
   openapi:
   openapi:
     path: openapi
     path: openapi
 
 
   # easy to remove packages:
   # easy to remove packages:
   image_picker: ^0.8.5+3 # only used to select user profile image from system gallery -> we can simply select an image from within immich?
   image_picker: ^0.8.5+3 # only used to select user profile image from system gallery -> we can simply select an image from within immich?
+  logging: ^1.1.0
 
 
 dev_dependencies:
 dev_dependencies:
   flutter_test:
   flutter_test:
@@ -70,7 +72,9 @@ flutter:
     - family: SnowburstOne
     - family: SnowburstOne
       fonts:
       fonts:
         - asset: fonts/SnowburstOne.ttf
         - asset: fonts/SnowburstOne.ttf
-
+    - family: Inconsolata
+      fonts:
+        - asset: fonts/Inconsolata-Regular.ttf
 flutter_icons:
 flutter_icons:
   image_path_android: "assets/immich-logo-no-outline.png"
   image_path_android: "assets/immich-logo-no-outline.png"
   image_path_ios: "assets/immich-logo-no-outline.png"
   image_path_ios: "assets/immich-logo-no-outline.png"

+ 16 - 2
server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts

@@ -18,7 +18,14 @@ export class AlbumResponseDto {
 }
 }
 
 
 export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
 export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
-  const sharedUsers = entity.sharedUsers?.map((userAlbum) => mapUser(userAlbum.userInfo)) || [];
+  const sharedUsers: UserResponseDto[] = [];
+
+  entity.sharedUsers?.forEach((userAlbum) => {
+    if (userAlbum.userInfo) {
+      const user = mapUser(userAlbum.userInfo);
+      sharedUsers.push(user);
+    }
+  });
   return {
   return {
     albumName: entity.albumName,
     albumName: entity.albumName,
     albumThumbnailAssetId: entity.albumThumbnailAssetId,
     albumThumbnailAssetId: entity.albumThumbnailAssetId,
@@ -33,7 +40,14 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
 }
 }
 
 
 export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto {
 export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto {
-  const sharedUsers = entity.sharedUsers?.map((userAlbum) => mapUser(userAlbum.userInfo)) || [];
+  const sharedUsers: UserResponseDto[] = [];
+
+  entity.sharedUsers?.forEach((userAlbum) => {
+    if (userAlbum.userInfo) {
+      const user = mapUser(userAlbum.userInfo);
+      sharedUsers.push(user);
+    }
+  });
   return {
   return {
     albumName: entity.albumName,
     albumName: entity.albumName,
     albumThumbnailAssetId: entity.albumThumbnailAssetId,
     albumThumbnailAssetId: entity.albumThumbnailAssetId,

+ 13 - 5
server/apps/immich/src/api-v1/asset/asset.controller.ts

@@ -26,7 +26,7 @@ import { BackgroundTaskService } from '../../modules/background-task/background-
 import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { SearchAssetDto } from './dto/search-asset.dto';
 import { SearchAssetDto } from './dto/search-asset.dto';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
-import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
+import { ApiBearerAuth, ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
 import { AssetResponseDto } from './response-dto/asset-response.dto';
 import { AssetResponseDto } from './response-dto/asset-response.dto';
@@ -110,7 +110,7 @@ export class AssetController {
   }
   }
 
 
   @Get('/file/:assetId')
   @Get('/file/:assetId')
-  @Header('Cache-Control', 'max-age=300')
+  @Header('Cache-Control', 'max-age=3600')
   async serveFile(
   async serveFile(
     @GetAuthUser() authUser: AuthUserDto,
     @GetAuthUser() authUser: AuthUserDto,
     @Headers() headers: Record<string, string>,
     @Headers() headers: Record<string, string>,
@@ -123,15 +123,16 @@ export class AssetController {
   }
   }
 
 
   @Get('/thumbnail/:assetId')
   @Get('/thumbnail/:assetId')
-  @Header('Cache-Control', 'max-age=300')
+  @Header('Cache-Control', 'max-age=3600')
   async getAssetThumbnail(
   async getAssetThumbnail(
     @GetAuthUser() authUser: AuthUserDto,
     @GetAuthUser() authUser: AuthUserDto,
+    @Headers() headers: Record<string, string>,
     @Response({ passthrough: true }) res: Res,
     @Response({ passthrough: true }) res: Res,
     @Param('assetId') assetId: string,
     @Param('assetId') assetId: string,
     @Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
     @Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
   ): Promise<any> {
   ): Promise<any> {
     await this.assetService.checkAssetsAccess(authUser, [assetId]);
     await this.assetService.checkAssetsAccess(authUser, [assetId]);
-    return this.assetService.getAssetThumbnail(assetId, query, res);
+    return this.assetService.getAssetThumbnail(assetId, query, res, headers);
   }
   }
 
 
   @Get('/curated-objects')
   @Get('/curated-objects')
@@ -174,8 +175,15 @@ export class AssetController {
    * Get all AssetEntity belong to the user
    * Get all AssetEntity belong to the user
    */
    */
   @Get('/')
   @Get('/')
+  @ApiHeader({
+    name: 'if-none-match',
+    description: 'ETag of data already cached on the client',
+    required: false,
+    schema: { type: 'string' },
+  })
   async getAllAssets(@GetAuthUser() authUser: AuthUserDto): Promise<AssetResponseDto[]> {
   async getAllAssets(@GetAuthUser() authUser: AuthUserDto): Promise<AssetResponseDto[]> {
-    return await this.assetService.getAllAssets(authUser);
+    const assets = await this.assetService.getAllAssets(authUser);
+    return assets;
   }
   }
 
 
   @Post('/time-bucket')
   @Post('/time-bucket')

+ 47 - 25
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -306,7 +306,12 @@ export class AssetService {
     }
     }
   }
   }
 
 
-  public async getAssetThumbnail(assetId: string, query: GetAssetThumbnailDto, res: Res) {
+  public async getAssetThumbnail(
+    assetId: string,
+    query: GetAssetThumbnailDto,
+    res: Res,
+    headers: Record<string, string>,
+  ) {
     let fileReadStream: ReadStream;
     let fileReadStream: ReadStream;
 
 
     const asset = await this.assetRepository.findOne({ where: { id: assetId } });
     const asset = await this.assetRepository.findOne({ where: { id: assetId } });
@@ -316,28 +321,22 @@ export class AssetService {
     }
     }
 
 
     try {
     try {
-      if (query.format == GetAssetThumbnailFormatEnum.JPEG) {
+      if (query.format == GetAssetThumbnailFormatEnum.WEBP && asset.webpPath && asset.webpPath.length > 0) {
+        if (await processETag(asset.webpPath, res, headers)) {
+          return;
+        }
+        await fs.access(asset.webpPath, constants.R_OK);
+        fileReadStream = createReadStream(asset.webpPath);
+      } else {
         if (!asset.resizePath) {
         if (!asset.resizePath) {
           throw new NotFoundException('resizePath not set');
           throw new NotFoundException('resizePath not set');
         }
         }
-
-        await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
-        fileReadStream = createReadStream(asset.resizePath);
-      } else {
-        if (asset.webpPath && asset.webpPath.length > 0) {
-          await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
-          fileReadStream = createReadStream(asset.webpPath);
-        } else {
-          if (!asset.resizePath) {
-            throw new NotFoundException('resizePath not set');
-          }
-
-          await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
-          fileReadStream = createReadStream(asset.resizePath);
+        if (await processETag(asset.resizePath, res, headers)) {
+          return;
         }
         }
+        await fs.access(asset.resizePath, constants.R_OK);
+        fileReadStream = createReadStream(asset.resizePath);
       }
       }
-
-      res.header('Cache-Control', 'max-age=300');
       return new StreamableFile(fileReadStream);
       return new StreamableFile(fileReadStream);
     } catch (e) {
     } catch (e) {
       res.header('Cache-Control', 'none');
       res.header('Cache-Control', 'none');
@@ -349,7 +348,7 @@ export class AssetService {
     }
     }
   }
   }
 
 
-  public async serveFile(assetId: string, query: ServeFileDto, res: Res, headers: any) {
+  public async serveFile(assetId: string, query: ServeFileDto, res: Res, headers: Record<string, string>) {
     let fileReadStream: ReadStream;
     let fileReadStream: ReadStream;
     const asset = await this._assetRepository.getById(assetId);
     const asset = await this._assetRepository.getById(assetId);
 
 
@@ -371,6 +370,9 @@ export class AssetService {
             Logger.error('Error serving IMAGE asset for web', 'ServeFile');
             Logger.error('Error serving IMAGE asset for web', 'ServeFile');
             throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
             throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
           }
           }
+          if (await processETag(asset.resizePath, res, headers)) {
+            return;
+          }
           await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
           await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
           fileReadStream = createReadStream(asset.resizePath);
           fileReadStream = createReadStream(asset.resizePath);
 
 
@@ -384,7 +386,9 @@ export class AssetService {
           res.set({
           res.set({
             'Content-Type': asset.mimeType,
             'Content-Type': asset.mimeType,
           });
           });
-
+          if (await processETag(asset.originalPath, res, headers)) {
+            return;
+          }
           await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
           await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
           fileReadStream = createReadStream(asset.originalPath);
           fileReadStream = createReadStream(asset.originalPath);
         } else {
         } else {
@@ -392,7 +396,9 @@ export class AssetService {
             res.set({
             res.set({
               'Content-Type': 'image/webp',
               'Content-Type': 'image/webp',
             });
             });
-
+            if (await processETag(asset.webpPath, res, headers)) {
+              return;
+            }
             await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
             await fs.access(asset.webpPath, constants.R_OK | constants.W_OK);
             fileReadStream = createReadStream(asset.webpPath);
             fileReadStream = createReadStream(asset.webpPath);
           } else {
           } else {
@@ -403,6 +409,9 @@ export class AssetService {
             if (!asset.resizePath) {
             if (!asset.resizePath) {
               throw new Error('resizePath not set');
               throw new Error('resizePath not set');
             }
             }
+            if (await processETag(asset.resizePath, res, headers)) {
+              return;
+            }
 
 
             await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
             await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
             fileReadStream = createReadStream(asset.resizePath);
             fileReadStream = createReadStream(asset.resizePath);
@@ -436,9 +445,9 @@ export class AssetService {
 
 
         if (range) {
         if (range) {
           /** Extracting Start and End value from Range Header */
           /** Extracting Start and End value from Range Header */
-          let [start, end] = range.replace(/bytes=/, '').split('-');
-          start = parseInt(start, 10);
-          end = end ? parseInt(end, 10) : size - 1;
+          const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
+          let start = parseInt(startStr, 10);
+          let end = endStr ? parseInt(endStr, 10) : size - 1;
 
 
           if (!isNaN(start) && isNaN(end)) {
           if (!isNaN(start) && isNaN(end)) {
             start = start;
             start = start;
@@ -475,7 +484,9 @@ export class AssetService {
           res.set({
           res.set({
             'Content-Type': mimeType,
             'Content-Type': mimeType,
           });
           });
-
+          if (await processETag(asset.originalPath, res, headers)) {
+            return;
+          }
           return new StreamableFile(createReadStream(videoPath));
           return new StreamableFile(createReadStream(videoPath));
         }
         }
       } catch (e) {
       } catch (e) {
@@ -651,3 +662,14 @@ export class AssetService {
     }
     }
   }
   }
 }
 }
+
+async function processETag(path: string, res: Res, headers: Record<string, string>): Promise<boolean> {
+  const { size, mtimeNs } = await fs.stat(path, { bigint: true });
+  const etag = `W/"${size}-${mtimeNs}"`;
+  res.setHeader('ETag', etag);
+  if (etag === headers['if-none-match']) {
+    res.status(304);
+    return true;
+  }
+  return false;
+}

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