Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5596cf6a2 | ||
|
|
b0d5c7035b | ||
|
|
3497a0de54 | ||
|
|
117f2fa00d | ||
|
|
2c67090e3c | ||
|
|
b49f66bbc9 | ||
|
|
9adbbd42be | ||
|
|
8563bd463c | ||
|
|
da5a6d2272 | ||
|
|
0854737be2 | ||
|
|
f0e272d0f2 | ||
|
|
e0b80f49b6 | ||
|
|
089dbdbd7e | ||
|
|
4e526dfaae | ||
|
|
cae37657e9 | ||
|
|
1a94530935 | ||
|
|
cd59f7aad6 | ||
|
|
c74fba483d | ||
|
|
2400004f41 | ||
|
|
b862c20e8e | ||
|
|
501b96baf7 | ||
|
|
d2600e0ddd | ||
|
|
7d799b785e | ||
|
|
e36b620020 | ||
|
|
1efc74dabc | ||
|
|
54f98053a8 | ||
|
|
6745826f35 | ||
|
|
bbd897b8ff | ||
|
|
586590e9ec | ||
|
|
4bf50a0b46 | ||
|
|
40832f0ea7 | ||
|
|
32a065afc7 | ||
|
|
4dafc74223 | ||
|
|
8adf1231a3 | ||
|
|
c00624f209 | ||
|
|
eccde8fa07 | ||
|
|
0616a66b05 | ||
|
|
67453d18ff | ||
|
|
792a87e407 | ||
|
|
6da50626e1 | ||
|
|
6239b3b309 | ||
|
|
b9bc621e2a | ||
|
|
e10bbfa933 | ||
|
|
2dd301e292 | ||
|
|
75edc6de0f | ||
|
|
780c5183e3 | ||
|
|
25a10784eb | ||
|
|
6e1d09fc32 | ||
|
|
73a2063d96 | ||
|
|
deb1e7f41f | ||
|
|
f45f719b9d | ||
|
|
325639b308 | ||
|
|
386eef046d | ||
|
|
db6b14361d | ||
|
|
719f074ccf | ||
|
|
646b912da8 | ||
|
|
b29c43d86a | ||
|
|
7ce64ecf05 | ||
|
|
9a332074c7 | ||
|
|
dd02f1025f | ||
|
|
d7bfab7b13 | ||
|
|
05cf5d57a9 | ||
|
|
f56eaae019 | ||
|
|
0d436db3ea | ||
|
|
6c8b29f326 | ||
|
|
23e76b0bd9 | ||
|
|
82e8cd0f8d | ||
|
|
87d84b922f | ||
|
|
04955a4123 | ||
|
|
54831878e0 | ||
|
|
08ed71e51e | ||
|
|
3a1d5de742 | ||
|
|
e15be5bf9a | ||
|
|
01afeefeb9 | ||
|
|
532bd6fe12 | ||
|
|
416e30ede2 | ||
|
|
ceb81d00fc | ||
|
|
8adca31c24 | ||
|
|
3cce43309c | ||
|
|
63ad802013 | ||
|
|
9313e70575 | ||
|
|
838ea56605 | ||
|
|
9ac087c59c | ||
|
|
f52e076cb3 | ||
|
|
8857d0b8df | ||
|
|
950989a85e | ||
|
|
a4c215751e | ||
|
|
2ca560ebf8 | ||
|
|
1f631eafce | ||
|
|
6f605d4a35 | ||
|
|
1918625be9 | ||
|
|
bdf35b6688 | ||
|
|
2ac54ce4bd | ||
|
|
96d75c9ad4 | ||
|
|
dac4020f27 | ||
|
|
a5f49b065c | ||
|
|
d5d0624311 | ||
|
|
8708867c1c | ||
|
|
8f11529a75 | ||
|
|
0aaeab124d | ||
|
|
1cc184ed10 | ||
|
|
830f4268c3 | ||
|
|
977740045a | ||
|
|
2a1dcbc28b | ||
|
|
21f8ab647f | ||
|
|
aef5a48fc6 | ||
|
|
434c1a0f20 | ||
|
|
5fd2496774 | ||
|
|
7411bcbb30 |
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v2.1.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.4.1
|
||||
uses: docker/setup-buildx-action@v2.5.0
|
||||
# Workaround to fix error:
|
||||
# failed to push: failed to copy: io: read/write on closed pipe
|
||||
# See https://github.com/docker/build-push-action/issues/761
|
||||
|
||||
5
.github/workflows/prepare-release.yml
vendored
5
.github/workflows/prepare-release.yml
vendored
@@ -41,8 +41,9 @@ jobs:
|
||||
id: push-tag
|
||||
uses: EndBug/add-and-commit@v9
|
||||
with:
|
||||
author_name: Immich Release Bot
|
||||
author_email: bot@immich.app
|
||||
author_name: Alex The Bot
|
||||
author_email: alex.tran1502@gmail.com
|
||||
default_author: user_info
|
||||
message: "Version ${{ env.IMMICH_VERSION }}"
|
||||
tag: ${{ env.IMMICH_VERSION }}
|
||||
push: true
|
||||
|
||||
152
.github/workflows/test.yml
vendored
152
.github/workflows/test.yml
vendored
@@ -52,8 +52,8 @@ jobs:
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: '3.7.3'
|
||||
channel: "stable"
|
||||
flutter-version: "3.7.3"
|
||||
- name: Run tests
|
||||
working-directory: ./mobile
|
||||
run: flutter test
|
||||
@@ -124,77 +124,77 @@ jobs:
|
||||
echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}"
|
||||
exit 1
|
||||
|
||||
mobile-integration-tests:
|
||||
name: Run mobile end-to-end integration tests
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '12.x'
|
||||
cache: 'gradle'
|
||||
- name: Cache android SDK
|
||||
uses: actions/cache@v3
|
||||
id: android-sdk
|
||||
with:
|
||||
key: android-sdk
|
||||
path: |
|
||||
/usr/local/lib/android/
|
||||
~/.android
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
./mobile/build/
|
||||
./mobile/android/.gradle/
|
||||
key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }}
|
||||
- name: Setup Android SDK
|
||||
if: steps.android-sdk.outputs.cache-hit != 'true'
|
||||
uses: android-actions/setup-android@v2
|
||||
- name: AVD cache
|
||||
uses: actions/cache@v3
|
||||
id: avd-cache
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/*
|
||||
~/.android/adb*
|
||||
key: avd-29
|
||||
- name: create AVD and generate snapshot for caching
|
||||
if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@v2.27.0
|
||||
with:
|
||||
working-directory: ./mobile
|
||||
cores: 2
|
||||
api-level: 29
|
||||
arch: x86_64
|
||||
profile: pixel
|
||||
target: default
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
disable-animations: false
|
||||
script: echo "Generated AVD snapshot for caching."
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: '3.7.3'
|
||||
cache: true
|
||||
- name: Run integration tests
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
action: reactivecircus/android-emulator-runner@v2.27.0
|
||||
with: |
|
||||
working-directory: ./mobile
|
||||
cores: 2
|
||||
api-level: 29
|
||||
arch: x86_64
|
||||
profile: pixel
|
||||
target: default
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
disable-animations: true
|
||||
script: |
|
||||
flutter pub get
|
||||
flutter test integration_test
|
||||
attempt_limit: 3
|
||||
# mobile-integration-tests:
|
||||
# name: Run mobile end-to-end integration tests
|
||||
# runs-on: macos-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v3
|
||||
# - uses: actions/setup-java@v3
|
||||
# with:
|
||||
# distribution: 'zulu'
|
||||
# java-version: '12.x'
|
||||
# cache: 'gradle'
|
||||
# - name: Cache android SDK
|
||||
# uses: actions/cache@v3
|
||||
# id: android-sdk
|
||||
# with:
|
||||
# key: android-sdk
|
||||
# path: |
|
||||
# /usr/local/lib/android/
|
||||
# ~/.android
|
||||
# - name: Cache Gradle
|
||||
# uses: actions/cache@v3
|
||||
# with:
|
||||
# path: |
|
||||
# ./mobile/build/
|
||||
# ./mobile/android/.gradle/
|
||||
# key: ${{ runner.os }}-flutter-${{ hashFiles('**/*.gradle*', 'pubspec.lock') }}
|
||||
# - name: Setup Android SDK
|
||||
# if: steps.android-sdk.outputs.cache-hit != 'true'
|
||||
# uses: android-actions/setup-android@v2
|
||||
# - name: AVD cache
|
||||
# uses: actions/cache@v3
|
||||
# id: avd-cache
|
||||
# with:
|
||||
# path: |
|
||||
# ~/.android/avd/*
|
||||
# ~/.android/adb*
|
||||
# key: avd-29
|
||||
# - name: create AVD and generate snapshot for caching
|
||||
# if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
# uses: reactivecircus/android-emulator-runner@v2.27.0
|
||||
# with:
|
||||
# working-directory: ./mobile
|
||||
# cores: 2
|
||||
# api-level: 29
|
||||
# arch: x86_64
|
||||
# profile: pixel
|
||||
# target: default
|
||||
# force-avd-creation: false
|
||||
# emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
# disable-animations: false
|
||||
# script: echo "Generated AVD snapshot for caching."
|
||||
# - name: Setup Flutter SDK
|
||||
# uses: subosito/flutter-action@v2
|
||||
# with:
|
||||
# channel: 'stable'
|
||||
# flutter-version: '3.7.3'
|
||||
# cache: true
|
||||
# - name: Run integration tests
|
||||
# uses: Wandalen/wretry.action@master
|
||||
# with:
|
||||
# action: reactivecircus/android-emulator-runner@v2.27.0
|
||||
# with: |
|
||||
# working-directory: ./mobile
|
||||
# cores: 2
|
||||
# api-level: 29
|
||||
# arch: x86_64
|
||||
# profile: pixel
|
||||
# target: default
|
||||
# force-avd-creation: false
|
||||
# emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
# disable-animations: true
|
||||
# script: |
|
||||
# flutter pub get
|
||||
# flutter test integration_test
|
||||
# attempt_limit: 3
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "mobile/.isar"]
|
||||
path = mobile/.isar
|
||||
url = https://github.com/isar/isar
|
||||
@@ -1,17 +0,0 @@
|
||||
# Deployment checklist for iOS/Android/Server
|
||||
|
||||
[ ] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
|
||||
|
||||
[ ] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
|
||||
|
||||
[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
|
||||
|
||||
[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.dev.yml) for `immich_server` service
|
||||
|
||||
[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
|
||||
|
||||
[ ] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
|
||||
|
||||
[ ] Add changelog to [Android Fastlane F-droid folder](/mobile/android/fastlane/metadata/android/en-US/changelogs)
|
||||
|
||||
All of the version should be the same.
|
||||
@@ -17,3 +17,5 @@ ENABLE_MAPBOX=false
|
||||
# WEB
|
||||
MAPBOX_KEY=
|
||||
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
||||
|
||||
TYPESENSE_ENABLED=false
|
||||
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
- typesense
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
@@ -64,6 +65,7 @@ services:
|
||||
depends_on:
|
||||
- database
|
||||
- immich-server
|
||||
- typesense
|
||||
|
||||
immich-web:
|
||||
container_name: immich_web
|
||||
@@ -89,6 +91,17 @@ services:
|
||||
depends_on:
|
||||
- immich-server
|
||||
|
||||
typesense:
|
||||
container_name: immich_typesense
|
||||
image: typesense/typesense:0.24.0
|
||||
environment:
|
||||
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
|
||||
- TYPESENSE_DATA_DIR=/data
|
||||
logging:
|
||||
driver: none
|
||||
volumes:
|
||||
- tsdata:/data
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2
|
||||
@@ -129,3 +142,4 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
model-cache:
|
||||
tsdata:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
immich-server-test:
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
target: builder
|
||||
command: npm run test:e2e
|
||||
expose:
|
||||
- '3000'
|
||||
- "3000"
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- /usr/src/app/node_modules
|
||||
@@ -17,6 +17,7 @@ services:
|
||||
- .env.test
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- TYPESENSE_ENABLED=false
|
||||
depends_on:
|
||||
- immich-redis-test
|
||||
- immich-database-test
|
||||
|
||||
@@ -3,8 +3,8 @@ version: "3.8"
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: altran1502/immich-server:release
|
||||
entrypoint: [ "/bin/sh", "./start-server.sh" ]
|
||||
image: ghcr.io/immich-app/immich-server:release
|
||||
entrypoint: ["/bin/sh", "./start-server.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -14,12 +14,13 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
- typesense
|
||||
restart: always
|
||||
|
||||
immich-microservices:
|
||||
container_name: immich_microservices
|
||||
image: altran1502/immich-server:release
|
||||
entrypoint: [ "/bin/sh", "./start-microservices.sh" ]
|
||||
image: ghcr.io/immich-app/immich-server:release
|
||||
entrypoint: ["/bin/sh", "./start-microservices.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
env_file:
|
||||
@@ -29,11 +30,12 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
- typesense
|
||||
restart: always
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
image: altran1502/immich-machine-learning:release
|
||||
image: ghcr.io/immich-app/immich-machine-learning:release
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- model-cache:/cache
|
||||
@@ -45,12 +47,24 @@ services:
|
||||
|
||||
immich-web:
|
||||
container_name: immich_web
|
||||
image: altran1502/immich-web:release
|
||||
entrypoint: [ "/bin/sh", "./entrypoint.sh" ]
|
||||
image: ghcr.io/immich-app/immich-web:release
|
||||
entrypoint: ["/bin/sh", "./entrypoint.sh"]
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
|
||||
typesense:
|
||||
container_name: immich_typesense
|
||||
image: typesense/typesense:0.24.0
|
||||
environment:
|
||||
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
|
||||
- TYPESENSE_DATA_DIR=/data
|
||||
logging:
|
||||
driver: none
|
||||
volumes:
|
||||
- tsdata:/data
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2
|
||||
@@ -72,7 +86,7 @@ services:
|
||||
|
||||
immich-proxy:
|
||||
container_name: immich_proxy
|
||||
image: altran1502/immich-proxy:release
|
||||
image: ghcr.io/immich-app/immich-proxy:release
|
||||
environment:
|
||||
# Make sure these values get passed through from the env file
|
||||
- IMMICH_SERVER_URL
|
||||
@@ -88,3 +102,4 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
model-cache:
|
||||
tsdata:
|
||||
|
||||
@@ -17,6 +17,11 @@ DB_DATABASE_NAME=immich
|
||||
REDIS_HOSTNAME=immich_redis
|
||||
|
||||
# Optional Redis settings:
|
||||
|
||||
# Note: these parameters are not automatically passed to the Redis Container
|
||||
# to do so, please edit the docker-compose.yml file as well. Redis is not configured
|
||||
# via environment variables, only redis.conf or the command line
|
||||
|
||||
# REDIS_PORT=6379
|
||||
# REDIS_DBINDEX=0
|
||||
# REDIS_PASSWORD=
|
||||
@@ -30,6 +35,13 @@ REDIS_HOSTNAME=immich_redis
|
||||
|
||||
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
|
||||
|
||||
|
||||
###################################################################################
|
||||
# Typesense
|
||||
###################################################################################
|
||||
TYPESENSE_API_KEY=some-random-text
|
||||
# TYPESENSE_ENABLED=false
|
||||
|
||||
###################################################################################
|
||||
# Reverse Geocoding
|
||||
#
|
||||
@@ -76,4 +88,4 @@ IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
|
||||
# Examples: http://localhost:3001, http://immich-api.example.com, etc
|
||||
####################################################################################
|
||||
|
||||
#IMMICH_API_URL_EXTERNAL=http://localhost:3001
|
||||
#IMMICH_API_URL_EXTERNAL=http://localhost:3001
|
||||
|
||||
@@ -14,16 +14,38 @@ sidebar_position: 7
|
||||
|
||||
### How can I sync an existing directory with Immich's server?
|
||||
|
||||
Immich doesn't have the mechanism to sync an existing directory with the server. There is however, a helper CLI tool to help you bulk upload the existing photos and videos to the server. You can find the guide to use the CLI tool [here](/docs/features/bulk-upload.md).
|
||||
Immich doesn't have two-way synchronization ([yet](https://github.com/immich-app/immich/discussions/1006)), but the [command line tool](/docs/features/bulk-upload.md) can bulk upload items from a directory to Immich.
|
||||
|
||||
### Why doesn't Immich watch an existing photo gallery directory?
|
||||
|
||||
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.
|
||||
|
||||
### Why does my uploaded photo show up with the wrong date or time in Immich?
|
||||
|
||||
When a photo is initially uploaded Immich uses the create date of the file to determine where it belongs in the timeline. After that, background jobs will run that extract [exif metadata](https://en.wikipedia.org/wiki/Exif), including the CreateDate, to provide a more accurate date for the photo. If that is not available it will fallback to the modified date. If you want to ensure your photo has the right date, check the exif metadata before uploading.
|
||||
|
||||
If the timezone is incorrect in an uploaded photo, check the ``DateTimeOriginal`` exif field of the uploaded file. Immich uses the very competent library [exiftool-vendored.js](https://github.com/photostructure/exiftool-vendored.js#dates) to handle timezone parsing, but in some cases (like photos taken with DSLR cameras) it has to fallback on the local timezone. If you are using docker, this fallback will be UTC. (Note that even the photo backup app that can't be named [has the same bug!](https://photo.stackexchange.com/a/126978)) In Immich, it is possible to change this assumed fallback timezone system-wide by setting the timezone in the microservices docker container. You might need to run the "Extract Metadata" job after to effect the change.
|
||||
|
||||
As an example, the following modification of ```docker-compose.yml``` will set the timezone of the microservices container to be ``Europe/Stockholm``
|
||||
|
||||
```
|
||||
environment:
|
||||
- TZ=Europe/Stockholm # <---- Add this line in the microservices config
|
||||
```
|
||||
|
||||
### Why are only photos and not videos being uploaded to Immich?
|
||||
This often happens when using a reverse proxy or cloudflare tunnel in front of Immich. Make sure to set your reverse proxy to allow large POST requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Cloudflare tunnels are limited to 100 mb file sizes.
|
||||
|
||||
### Why is Immich slow on low-memory systems like the Raspberry Pi?
|
||||
Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_URL=false` in your .env file.
|
||||
|
||||
### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)?
|
||||
|
||||
Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/administration/jobs.md) page.
|
||||
|
||||
### In the uploads folder, why are photos stored in the wrong date?
|
||||
This is fixed by running the storage migration job.
|
||||
|
||||
### Why is object detection not very good?
|
||||
|
||||
The model we used for machine learning is a prebuilt model, so the accuracy is not very good. It will hopefully be replaced with a better solution in the future.
|
||||
|
||||
22
docs/docs/administration/reverse-proxy.md
Normal file
22
docs/docs/administration/reverse-proxy.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Reverse Proxy
|
||||
|
||||
When deploying Immich it is important to understand that a reverse proxy is required in front of the server and web container. The reverse proxy acts as an intermediary between the user and container, forwarding requests to the correct container based on the URL path.
|
||||
|
||||
## Default Reverse Proxy
|
||||
|
||||
Immich provides a default nginx reverse proxy preconfigured to perform the correct routing and set the necessary headers for the server and web container to use. These headers are crucial to redirect to the correct URL and determine the client's IP address.
|
||||
|
||||
## Using a Different Reverse Proxy
|
||||
|
||||
While the reverse proxy provided by Immich works well for basic deployments, some users may want to use a different reverse proxy. Fortunately, Immich is flexible enough to accommodate different reverse proxies. Users can either:
|
||||
|
||||
1. Add another reverse proxy on top of Immich's reverse proxy
|
||||
2. Completely replace the default reverse proxy
|
||||
|
||||
## Adding a Custom Reverse Proxy
|
||||
|
||||
Users can deploy a custom reverse proxy that forwards requests to Immich's reverse proxy. This way, the new reverse proxy can handle TLS termination, load balancing, or other advanced features, while still delegating routing decisions to Immich's reverse proxy. All reverse proxies between Immich and the user must forward all headers and set the `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-For` headers to their appropriate values. By following these practices, you ensure that all custom reverse proxies are fully compatible with Immich.
|
||||
|
||||
## Replacing the Default Reverse Proxy
|
||||
|
||||
Replacing Immich's default reverse proxy is an advanced deployment and support may be limited. When replacing Immich's default proxy it is important to ensure that requests to `/api/*` are routed to the server container and all other requests to the web container. Additionally, the previously mentioned headers should be configured accordingly. You may find our [nginx configuration file](https://github.com/immich-app/immich/blob/main/nginx/templates/default.conf.template) a helpful reference.
|
||||
@@ -17,23 +17,29 @@ npm i -g immich
|
||||
|
||||
## Quick Start
|
||||
|
||||
Specify user's credentials, Immich's server address and port, and the directory you would like to upload videos/photos from.
|
||||
Specify user's credential, Immich's server address and port and the directory you would like to upload videos/photos from.
|
||||
|
||||
```bash
|
||||
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api -d your/target/directory
|
||||
```
|
||||
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api file1.jpg file2.jpg
|
||||
```
|
||||
|
||||
By default, subfolders are not included. To upload a directory including subfolder, use the --recursive option:
|
||||
|
||||
```
|
||||
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive directory/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Parameters
|
||||
### Options
|
||||
|
||||
| Parameter | Description |
|
||||
| ---------------- | ------------------------------------------------------------------- |
|
||||
| --yes / -y | Assume yes on all interactive prompts |
|
||||
| --recursive / -r | Include subfolders |
|
||||
| --delete / -da | Delete local assets after upload |
|
||||
| --key / -k | User's API key |
|
||||
| --server / -s | Immich's server address |
|
||||
| --directory / -d | Directory to upload from |
|
||||
| --threads / -t | Number of threads to use (Default 5) |
|
||||
| --album/ -al | Create albums for assets based on the parent folder or a given name |
|
||||
|
||||
@@ -92,5 +98,5 @@ npm run build
|
||||
```
|
||||
|
||||
```bash title="Run the command"
|
||||
node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api -d your/target/directory
|
||||
node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive your/asset/directory
|
||||
```
|
||||
|
||||
@@ -46,6 +46,11 @@ DB_DATABASE_NAME=immich
|
||||
REDIS_HOSTNAME=immich_redis
|
||||
|
||||
# Optional Redis settings:
|
||||
|
||||
# Note: these parameters are not automatically passed to the Redis Container
|
||||
# to do so, please edit the docker-compose.yml file as well. Redis is not configured
|
||||
# via environment variables, only redis.conf or the command line
|
||||
|
||||
# REDIS_PORT=6379
|
||||
# REDIS_DBINDEX=0
|
||||
# REDIS_PASSWORD=
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
FROM python:3.10
|
||||
FROM python:3.10 as builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=true
|
||||
|
||||
RUN python -m venv /opt/venv
|
||||
RUN /opt/venv/bin/pip install --pre torch -f https://download.pytorch.org/whl/nightly/cpu/torch_nightly.html
|
||||
RUN /opt/venv/bin/pip install transformers tqdm numpy scikit-learn scipy nltk sentencepiece flask Pillow gunicorn
|
||||
RUN /opt/venv/bin/pip install --no-deps sentence-transformers
|
||||
|
||||
FROM python:3.10-slim
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
ENV TRANSFORMERS_CACHE=/cache \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=true
|
||||
PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
RUN pip install --pre torch -f https://download.pytorch.org/whl/nightly/cpu/torch_nightly.html
|
||||
RUN pip install transformers tqdm numpy scikit-learn scipy nltk sentencepiece flask Pillow
|
||||
RUN pip install --no-deps sentence-transformers
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "src/main.py"]
|
||||
CMD ["gunicorn", "src.main:server"]
|
||||
|
||||
29
machine-learning/gunicorn.conf.py
Normal file
29
machine-learning/gunicorn.conf.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Gunicorn configuration options.
|
||||
https://docs.gunicorn.org/en/stable/settings.html
|
||||
"""
|
||||
import os
|
||||
|
||||
|
||||
# Set the bind address based on the env
|
||||
port = os.getenv("MACHINE_LEARNING_PORT") or "3003"
|
||||
listen_ip = os.getenv("MACHINE_LEARNING_IP") or "0.0.0.0"
|
||||
bind = [f"{listen_ip}:{port}"]
|
||||
|
||||
# Preload the Flask app / models etc. before starting the server
|
||||
preload_app = True
|
||||
|
||||
# Logging settings - log to stdout and set log level
|
||||
accesslog = "-"
|
||||
loglevel = os.getenv("MACHINE_LEARNING_LOG_LEVEL") or "info"
|
||||
|
||||
# Worker settings
|
||||
# ----------------------
|
||||
# It is important these are chosen carefully as per
|
||||
# https://pythonspeed.com/articles/gunicorn-in-docker/
|
||||
# Otherwise we get workers failing to respond to heartbeat checks,
|
||||
# especially as requests take a long time to complete.
|
||||
workers = 2
|
||||
threads = 4
|
||||
worker_tmp_dir = "/dev/shm"
|
||||
timeout = 60
|
||||
@@ -1,43 +1,58 @@
|
||||
import os
|
||||
from flask import Flask, request
|
||||
from transformers import pipeline
|
||||
from sentence_transformers import SentenceTransformer, util
|
||||
from PIL import Image
|
||||
|
||||
is_dev = os.getenv('NODE_ENV') == 'development'
|
||||
server_port = os.getenv('MACHINE_LEARNING_PORT', 3003)
|
||||
server_host = os.getenv('MACHINE_LEARNING_HOST', '0.0.0.0')
|
||||
|
||||
classification_model = os.getenv('MACHINE_LEARNING_CLASSIFICATION_MODEL', 'microsoft/resnet-50')
|
||||
object_model = os.getenv('MACHINE_LEARNING_OBJECT_MODEL', 'hustvl/yolos-tiny')
|
||||
clip_image_model = os.getenv('MACHINE_LEARNING_CLIP_IMAGE_MODEL', 'clip-ViT-B-32')
|
||||
clip_text_model = os.getenv('MACHINE_LEARNING_CLIP_TEXT_MODEL', 'clip-ViT-B-32')
|
||||
|
||||
_model_cache = {}
|
||||
def _get_model(model, task=None):
|
||||
global _model_cache
|
||||
key = '|'.join([model, str(task)])
|
||||
if key not in _model_cache:
|
||||
if task:
|
||||
_model_cache[key] = pipeline(model=model, task=task)
|
||||
else:
|
||||
_model_cache[key] = SentenceTransformer(model)
|
||||
return _model_cache[key]
|
||||
|
||||
server = Flask(__name__)
|
||||
|
||||
|
||||
classifier = pipeline(
|
||||
task="image-classification",
|
||||
model="microsoft/resnet-50"
|
||||
)
|
||||
|
||||
detector = pipeline(
|
||||
task="object-detection",
|
||||
model="hustvl/yolos-tiny"
|
||||
)
|
||||
|
||||
|
||||
# Environment resolver
|
||||
is_dev = os.getenv('NODE_ENV') == 'development'
|
||||
server_port = os.getenv('MACHINE_LEARNING_PORT') or 3003
|
||||
|
||||
|
||||
@server.route("/ping")
|
||||
def ping():
|
||||
return "pong"
|
||||
|
||||
|
||||
@server.route("/object-detection/detect-object", methods=['POST'])
|
||||
def object_detection():
|
||||
model = _get_model(object_model, 'object-detection')
|
||||
assetPath = request.json['thumbnailPath']
|
||||
return run_engine(detector, assetPath), 201
|
||||
|
||||
return run_engine(model, assetPath), 200
|
||||
|
||||
@server.route("/image-classifier/tag-image", methods=['POST'])
|
||||
def image_classification():
|
||||
model = _get_model(classification_model, 'image-classification')
|
||||
assetPath = request.json['thumbnailPath']
|
||||
return run_engine(classifier, assetPath), 201
|
||||
return run_engine(model, assetPath), 200
|
||||
|
||||
@server.route("/sentence-transformer/encode-image", methods=['POST'])
|
||||
def clip_encode_image():
|
||||
model = _get_model(clip_image_model)
|
||||
assetPath = request.json['thumbnailPath']
|
||||
return model.encode(Image.open(assetPath)).tolist(), 200
|
||||
|
||||
@server.route("/sentence-transformer/encode-text", methods=['POST'])
|
||||
def clip_encode_text():
|
||||
model = _get_model(clip_text_model)
|
||||
text = request.json['text']
|
||||
return model.encode(text).tolist(), 200
|
||||
|
||||
def run_engine(engine, path):
|
||||
result = []
|
||||
@@ -55,4 +70,4 @@ def run_engine(engine, path):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server.run(debug=is_dev, host='0.0.0.0', port=server_port)
|
||||
server.run(debug=is_dev, host=server_host, port=server_port)
|
||||
|
||||
1
mobile/.isar
Submodule
1
mobile/.isar
Submodule
Submodule mobile/.isar added at 70da4e0bbd
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 73,
|
||||
"android.injected.version.name" => "1.50.0",
|
||||
"android.injected.version.code" => 75,
|
||||
"android.injected.version.name" => "1.52.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')
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
* fix: Prevents duplicate taps navigating to the same route twice.
|
||||
* fix: Adds safe area to album to stop from clipping bottom of albums.
|
||||
* feat: Adds onboarding for permissions.
|
||||
* feat: Responsive list and grid view of backup album selection and fixes search filter.
|
||||
@@ -0,0 +1,12 @@
|
||||
* Enter server first for login
|
||||
* Sync assets, albums & users to local database on device
|
||||
* Fixes hero animation on main timeline by
|
||||
* Transparent bottom Android navigation bar
|
||||
* Fix do not crash on malformed asset duration
|
||||
* Gallery viewer fullscreen edge case
|
||||
* Fix Sorted shared album and added share user doesn't reflect change in album view
|
||||
* Allow app to be used offline
|
||||
* No longer wait for background backup in settings
|
||||
* Share album name and adaptive shared album display
|
||||
* Persist album sort order
|
||||
|
||||
@@ -5,17 +5,19 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000219">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000232">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="66.030339">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="70.685298">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="24.488297">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="33.624781">
|
||||
|
||||
<failure message="/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:229:in `chdir' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:229:in `execute_action' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing' Fastfile:42:in `block (2 levels) in parsing_binding' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/lane.rb:33:in `call' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:49:in `block in execute' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:45:in `chdir' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:45:in `execute' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:354:in `run' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:43:in `start' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/bin/fastlane:23:in `<top (required)>' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/bin/fastlane:25:in `load' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/bin/fastlane:25:in `<main>' Google Api Error: Invalid request - The release created has notes in language en-US with length 508, which is too long (max: 500)." />
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"common_change_password": "Change Password",
|
||||
"common_create_new_album": "Create new album",
|
||||
"common_shared": "Shared",
|
||||
"common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.",
|
||||
"control_bottom_app_bar_add_to_album": "Add to album",
|
||||
"control_bottom_app_bar_album_info": "{} items",
|
||||
"control_bottom_app_bar_album_info_shared": "{} items · Shared",
|
||||
@@ -142,6 +143,7 @@
|
||||
"library_page_sharing": "Sharing",
|
||||
"library_page_sort_created": "Most recently created",
|
||||
"library_page_sort_title": "Album title",
|
||||
"library_page_device_albums": "Albums on Device",
|
||||
"login_form_button_text": "Login",
|
||||
"login_form_email_hint": "youremail@email.com",
|
||||
"login_form_endpoint_hint": "http://your-server-ip:port/api",
|
||||
@@ -156,8 +158,11 @@
|
||||
"login_form_failed_login": "Error logging you in, check server URL, email and password",
|
||||
"login_form_label_email": "Email",
|
||||
"login_form_label_password": "Password",
|
||||
"login_form_password_hint": "password",
|
||||
"login_form_password_hint": "Password",
|
||||
"login_form_save_login": "Stay logged in",
|
||||
"login_form_server_empty": "Enter a server URL.",
|
||||
"login_form_server_error": "Could not connect to server.",
|
||||
"login_form_api_exception": "API exception. Please check the server URL and try again.",
|
||||
"monthly_title_text_date_format": "MMMM y",
|
||||
"notification_permission_dialog_cancel": "Cancel",
|
||||
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
|
||||
@@ -238,5 +243,24 @@
|
||||
"permission_onboarding_go_to_settings": "Go to settings",
|
||||
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
|
||||
"permission_onboarding_continue_anyway": "Continue anyway",
|
||||
"permission_onboarding_log_out": "Log out"
|
||||
"permission_onboarding_log_out": "Log out",
|
||||
"login_form_next_button": "Next",
|
||||
"album_thumbnail_shared_by": "Shared by {}",
|
||||
"album_thumbnail_owned": "Owned",
|
||||
"curated_object_page_title": "Things",
|
||||
"curated_location_page_title": "Places",
|
||||
"search_page_view_all_button": "View all",
|
||||
"search_page_your_activity": "Your activity",
|
||||
"search_page_favorites": "Favorites",
|
||||
"search_page_videos": "Videos",
|
||||
"all_videos_page_title": "Videos",
|
||||
"recently_added_page_title": "Recently Added",
|
||||
"motion_photos_page_title": "Motion Photos",
|
||||
"search_page_motion_photos": "Motion Photos",
|
||||
"search_page_recently_added": "Recently added",
|
||||
"search_page_categories": "Categories",
|
||||
"search_page_screenshots": "Screenshots",
|
||||
"search_page_selfies": "Selfies",
|
||||
"search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ",
|
||||
"search_suggestion_list_smart_search_hint_2": "m:your-search-term"
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
"version_announcement_overlay_ack": "Подтверждение",
|
||||
"version_announcement_overlay_release_notes": "примечания к выпуску",
|
||||
"version_announcement_overlay_text_1": "Привет друг, вышел новый релиз",
|
||||
"version_announcement_overlay_text_2": "пожалуйста, найдите время, чтобы посетить",
|
||||
"version_announcement_overlay_text_2": "пожалуйста, найдите время, чтобы посетить ",
|
||||
"version_announcement_overlay_text_3": " и убедитесь, что ваши настройки docker-compose и .env обновлены, чтобы предотвратить любые неправильные настройки, особенно если вы используете WatchTower или любой другой механизм, который обрабатывает обновление вашего серверного приложения автоматически.",
|
||||
"version_announcement_overlay_title": "Доступна новая версия сервера \uD83C\uDF89"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 536 KiB |
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
@@ -35,9 +34,7 @@ class ImmichTestHelper {
|
||||
}
|
||||
|
||||
static Future<void> loadApp(WidgetTester tester) async {
|
||||
// Clear all data from Hive
|
||||
await Hive.deleteFromDisk();
|
||||
await app.openBoxes();
|
||||
await EasyLocalization.ensureInitialized();
|
||||
// Clear all data from Isar (reuse existing instance if available)
|
||||
final db = Isar.getInstance() ?? await app.loadDb();
|
||||
await Store.clear();
|
||||
@@ -65,12 +62,13 @@ void immichWidgetTest(
|
||||
}
|
||||
|
||||
Future<void> pumpUntilFound(
|
||||
WidgetTester tester,
|
||||
Finder finder, {
|
||||
Duration timeout = const Duration(seconds: 120),
|
||||
}) async {
|
||||
WidgetTester tester,
|
||||
Finder finder, {
|
||||
Duration timeout = const Duration(seconds: 120),
|
||||
}) async {
|
||||
bool found = false;
|
||||
final timer = Timer(timeout, () => throw TimeoutException("Pump until has timed out"));
|
||||
final timer =
|
||||
Timer(timeout, () => throw TimeoutException("Pump until has timed out"));
|
||||
while (found != true) {
|
||||
await tester.pump();
|
||||
found = tester.any(finder);
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 87;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -514,7 +514,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 87;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -542,7 +542,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 87;
|
||||
CURRENT_PROJECT_VERSION = 88;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -45,11 +45,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.49.0</string>
|
||||
<string>1.50.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>87</string>
|
||||
<string>88</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.50.0"
|
||||
version_number: "1.52.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,19 +5,34 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000255">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000301">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="6.769548">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="1.613972">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="24.256379">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.887872">
|
||||
|
||||
<failure message="/opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:229:in `chdir' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:229:in `execute_action' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing' Fastfile:25:in `block (2 levels) in parsing_binding' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/lane.rb:33:in `call' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:49:in `block in execute' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:45:in `chdir' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/runner.rb:45:in `execute' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:354:in `run' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/commands_generator.rb:43:in `start' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/gems/fastlane-2.212.0/bin/fastlane:23:in `<top (required)>' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/bin/fastlane:25:in `load' /opt/homebrew/Cellar/fastlane/2.212.0/libexec/bin/fastlane:25:in `<main>' Access forbidden" />
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.53884">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="64.096001">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="6.017821">
|
||||
|
||||
<failure message="/usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in `execute_action' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:255:in `block in execute_action' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `chdir' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:229:in `execute_action' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:157:in `trigger_action_by_name' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/fast_file.rb:159:in `method_missing' Fastfile:30:in `block (2 levels) in parsing_binding' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane.rb:33:in `call' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:49:in `block in execute' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `chdir' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/runner.rb:45:in `execute' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/lane_manager.rb:47:in `cruise_lane' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/command_line_handler.rb:36:in `handle' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:110:in `block (2 levels) in run' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in `call' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in `run' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in `run_active_command' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in `run!' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in `run!' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:354:in `run' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/commands_generator.rb:43:in `start' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in `take_off' /usr/local/Cellar/fastlane/2.212.1/libexec/gems/fastlane-2.212.1/bin/fastlane:23:in `<top (required)>' /usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `load' /usr/local/Cellar/fastlane/2.212.1/libexec/bin/fastlane:25:in `<main>' Error uploading ipa file: [Application Loader Error Output]: Error uploading '/var/folders/lp/myp2frzj00g93mcbnz6cd8900000gn/T/279a4279-ff7e-48d3-b6fd-1ee020a5b0a9.ipa'.
|
||||
[Application Loader Error Output]: Unable to upload archive. Failed to get authorization for username 'alex.tran1502@gmail.com' and password. (
|
||||
[Application Loader Error Output]: The call to the altool completed with a non-zero exit status: 1. This indicates a failure." />
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
@@ -19,8 +21,13 @@ import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.pr
|
||||
import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
|
||||
import 'package:immich_mobile/shared/models/logger_message.model.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.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/db.provider.dart';
|
||||
@@ -36,34 +43,24 @@ import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'constants/hive_box.dart';
|
||||
|
||||
void main() async {
|
||||
await initApp();
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final db = await loadDb();
|
||||
await initApp();
|
||||
await migrateHiveToStoreIfNecessary();
|
||||
await migrateJsonCacheIfNecessary();
|
||||
await migrateDatabaseIfNeeded(db);
|
||||
runApp(getMainWidget(db));
|
||||
}
|
||||
|
||||
Future<void> openBoxes() async {
|
||||
await Future.wait([
|
||||
Hive.openBox<ImmichLoggerMessage>(immichLoggerBox),
|
||||
Hive.openBox(userInfoBox),
|
||||
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
|
||||
Hive.openBox(hiveGithubReleaseInfoBox),
|
||||
Hive.openBox(userSettingInfoBox),
|
||||
EasyLocalization.ensureInitialized(),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> initApp() async {
|
||||
await Hive.initFlutter();
|
||||
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
|
||||
Hive.registerAdapter(ImmichLoggerMessageAdapter());
|
||||
|
||||
await openBoxes();
|
||||
await EasyLocalization.ensureInitialized();
|
||||
|
||||
if (kReleaseMode && Platform.isAndroid) {
|
||||
try {
|
||||
@@ -75,7 +72,7 @@ Future<void> initApp() async {
|
||||
}
|
||||
|
||||
// Initialize Immich Logger Service
|
||||
ImmichLogger().init();
|
||||
ImmichLogger();
|
||||
|
||||
var log = Logger("ImmichErrorLogger");
|
||||
|
||||
@@ -93,7 +90,16 @@ Future<void> initApp() async {
|
||||
Future<Isar> loadDb() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
Isar db = await Isar.open(
|
||||
[StoreValueSchema],
|
||||
[
|
||||
StoreValueSchema,
|
||||
ExifInfoSchema,
|
||||
AssetSchema,
|
||||
AlbumSchema,
|
||||
UserSchema,
|
||||
BackupAlbumSchema,
|
||||
DuplicatedAssetSchema,
|
||||
LoggerMessageSchema,
|
||||
],
|
||||
directory: dir.path,
|
||||
maxSizeMiB: 256,
|
||||
);
|
||||
@@ -145,10 +151,12 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
||||
|
||||
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
|
||||
|
||||
ref.watch(notificationPermissionProvider.notifier)
|
||||
.getNotificationPermission();
|
||||
ref.watch(galleryPermissionNotifier.notifier)
|
||||
.getGalleryPermissionStatus();
|
||||
ref
|
||||
.watch(notificationPermissionProvider.notifier)
|
||||
.getNotificationPermission();
|
||||
ref
|
||||
.watch(galleryPermissionNotifier.notifier)
|
||||
.getGalleryPermissionStatus();
|
||||
|
||||
ref.read(iOSBackgroundSettingsProvider.notifier).refresh();
|
||||
|
||||
@@ -157,6 +165,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
||||
case AppLifecycleState.inactive:
|
||||
debugPrint("[APP STATE] inactive");
|
||||
ref.watch(appStateProvider.notifier).state = AppStateEnum.inactive;
|
||||
ImmichLogger().flush();
|
||||
ref.watch(websocketProvider.notifier).disconnect();
|
||||
ref.watch(backupProvider.notifier).cancelBackup();
|
||||
|
||||
@@ -200,6 +209,9 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
||||
ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo();
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||
);
|
||||
|
||||
return MaterialApp(
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class AlbumNotifier extends StateNotifier<List<Album>> {
|
||||
AlbumNotifier(this._albumService, this._albumCacheService) : super([]);
|
||||
AlbumNotifier(this._albumService, this._db) : super([]);
|
||||
final AlbumService _albumService;
|
||||
final AlbumCacheService _albumCacheService;
|
||||
|
||||
void _cacheState() {
|
||||
_albumCacheService.put(state);
|
||||
}
|
||||
final Isar _db;
|
||||
|
||||
Future<void> getAllAlbums() async {
|
||||
if (await _albumCacheService.isValid() && state.isEmpty) {
|
||||
final albums = await _albumCacheService.get();
|
||||
if (albums != null) {
|
||||
state = albums;
|
||||
}
|
||||
}
|
||||
|
||||
final albums = await _albumService.getAlbums(isShared: false);
|
||||
|
||||
if (albums != null) {
|
||||
final User me = Store.get(StoreKey.currentUser);
|
||||
List<Album> albums = await _db.albums
|
||||
.filter()
|
||||
.owner((q) => q.isarIdEqualTo(me.isarId))
|
||||
.findAll();
|
||||
if (!const ListEquality().equals(albums, state)) {
|
||||
state = albums;
|
||||
}
|
||||
await Future.wait([
|
||||
_albumService.refreshDeviceAlbums(),
|
||||
_albumService.refreshRemoteAlbums(isShared: false),
|
||||
]);
|
||||
albums = await _db.albums
|
||||
.filter()
|
||||
.owner((q) => q.isarIdEqualTo(me.isarId))
|
||||
.findAll();
|
||||
if (!const ListEquality().equals(albums, state)) {
|
||||
state = albums;
|
||||
_cacheState();
|
||||
}
|
||||
}
|
||||
|
||||
void deleteAlbum(Album album) {
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
state = state.where((a) => a.id != album.id).toList();
|
||||
_cacheState();
|
||||
return _albumService.deleteAlbum(album);
|
||||
}
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
@@ -39,20 +45,16 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
|
||||
Set<Asset> assets,
|
||||
) async {
|
||||
Album? album = await _albumService.createAlbum(albumTitle, assets, []);
|
||||
|
||||
if (album != null) {
|
||||
state = [...state, album];
|
||||
_cacheState();
|
||||
|
||||
return album;
|
||||
}
|
||||
return null;
|
||||
return album;
|
||||
}
|
||||
}
|
||||
|
||||
final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) {
|
||||
return AlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(albumCacheServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
|
||||
);
|
||||
}
|
||||
|
||||
void addNewAssets(List<Asset> assets) {
|
||||
void addNewAssets(Iterable<Asset> assets) {
|
||||
state = state.copyWith(
|
||||
selectedNewAssetsForAlbum: {
|
||||
...state.selectedNewAssetsForAlbum,
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
SharedAlbumNotifier(this._albumService, this._sharedAlbumCacheService)
|
||||
: super([]);
|
||||
SharedAlbumNotifier(this._albumService, this._db) : super([]);
|
||||
|
||||
final AlbumService _albumService;
|
||||
final SharedAlbumCacheService _sharedAlbumCacheService;
|
||||
|
||||
void _cacheState() {
|
||||
_sharedAlbumCacheService.put(state);
|
||||
}
|
||||
final Isar _db;
|
||||
|
||||
Future<Album?> createSharedAlbum(
|
||||
String albumName,
|
||||
@@ -23,7 +20,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
Iterable<User> sharedUsers,
|
||||
) async {
|
||||
try {
|
||||
var newAlbum = await _albumService.createAlbum(
|
||||
final Album? newAlbum = await _albumService.createAlbum(
|
||||
albumName,
|
||||
assets,
|
||||
sharedUsers,
|
||||
@@ -31,61 +28,52 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
|
||||
|
||||
if (newAlbum != null) {
|
||||
state = [...state, newAlbum];
|
||||
_cacheState();
|
||||
return newAlbum;
|
||||
}
|
||||
|
||||
return newAlbum;
|
||||
} catch (e) {
|
||||
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> getAllSharedAlbums() async {
|
||||
if (await _sharedAlbumCacheService.isValid() && state.isEmpty) {
|
||||
final albums = await _sharedAlbumCacheService.get();
|
||||
if (albums != null) {
|
||||
state = albums;
|
||||
}
|
||||
var albums = await _db.albums
|
||||
.filter()
|
||||
.sharedEqualTo(true)
|
||||
.sortByCreatedAtDesc()
|
||||
.findAll();
|
||||
if (!const ListEquality().equals(albums, state)) {
|
||||
state = albums;
|
||||
}
|
||||
|
||||
List<Album>? sharedAlbums = await _albumService.getAlbums(isShared: true);
|
||||
|
||||
if (sharedAlbums != null) {
|
||||
state = sharedAlbums;
|
||||
_cacheState();
|
||||
await _albumService.refreshRemoteAlbums(isShared: true);
|
||||
albums = await _db.albums
|
||||
.filter()
|
||||
.sharedEqualTo(true)
|
||||
.sortByCreatedAtDesc()
|
||||
.findAll();
|
||||
if (!const ListEquality().equals(albums, state)) {
|
||||
state = albums;
|
||||
}
|
||||
}
|
||||
|
||||
void deleteAlbum(Album album) {
|
||||
Future<bool> deleteAlbum(Album album) {
|
||||
state = state.where((a) => a.id != album.id).toList();
|
||||
_cacheState();
|
||||
return _albumService.deleteAlbum(album);
|
||||
}
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
var res = await _albumService.leaveAlbum(album);
|
||||
|
||||
if (res) {
|
||||
state = state.where((a) => a.id != album.id).toList();
|
||||
_cacheState();
|
||||
await deleteAlbum(album);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> removeAssetFromAlbum(
|
||||
Album album,
|
||||
Iterable<Asset> assets,
|
||||
) async {
|
||||
var res = await _albumService.removeAssetFromAlbum(album, assets);
|
||||
|
||||
if (res) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
|
||||
return _albumService.removeAssetFromAlbum(album, assets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,13 +81,15 @@ final sharedAlbumProvider =
|
||||
StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) {
|
||||
return SharedAlbumNotifier(
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(sharedAlbumCacheServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final sharedAlbumDetailProvider =
|
||||
FutureProvider.autoDispose.family<Album?, String>((ref, albumId) async {
|
||||
FutureProvider.autoDispose.family<Album?, int>((ref, albumId) async {
|
||||
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
|
||||
|
||||
return await sharedAlbumService.getAlbumDetail(albumId);
|
||||
final Album? a = await sharedAlbumService.getAlbumDetail(albumId);
|
||||
await a?.loadSortedAssets();
|
||||
return a;
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/services/user.service.dart';
|
||||
|
||||
final suggestedSharedUsersProvider =
|
||||
FutureProvider.autoDispose<List<User>>((ref) async {
|
||||
FutureProvider.autoDispose<List<User>>((ref) {
|
||||
UserService userService = ref.watch(userServiceProvider);
|
||||
|
||||
return await userService.getAllUsers(isAll: false) ?? [];
|
||||
return userService.getUsersInDb();
|
||||
});
|
||||
|
||||
@@ -1,34 +1,168 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||
import 'package:immich_mobile/shared/services/user.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final albumServiceProvider = Provider(
|
||||
(ref) => AlbumService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(backupServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class AlbumService {
|
||||
final ApiService _apiService;
|
||||
final UserService _userService;
|
||||
final SyncService _syncService;
|
||||
final Isar _db;
|
||||
final BackupService _backupService;
|
||||
final Logger _log = Logger('AlbumService');
|
||||
Completer<bool> _localCompleter = Completer()..complete(false);
|
||||
Completer<bool> _remoteCompleter = Completer()..complete(false);
|
||||
|
||||
AlbumService(this._apiService);
|
||||
AlbumService(
|
||||
this._apiService,
|
||||
this._userService,
|
||||
this._syncService,
|
||||
this._db,
|
||||
this._backupService,
|
||||
);
|
||||
|
||||
Future<List<Album>?> getAlbums({required bool isShared}) async {
|
||||
try {
|
||||
final dto = await _apiService.albumApi
|
||||
.getAllAlbums(shared: isShared ? isShared : null);
|
||||
return dto?.map(Album.remote).toList();
|
||||
} catch (e) {
|
||||
debugPrint("Error getAllSharedAlbum ${e.toString()}");
|
||||
return null;
|
||||
/// Checks all selected device albums for changes of albums and their assets
|
||||
/// Updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshDeviceAlbums() async {
|
||||
if (!_localCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
_log.info("refreshDeviceAlbums is already in progress");
|
||||
return _localCompleter.future;
|
||||
}
|
||||
_localCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
final List<String> excludedIds =
|
||||
await _backupService.excludedAlbumsQuery().idProperty().findAll();
|
||||
final List<String> selectedIds =
|
||||
await _backupService.selectedAlbumsQuery().idProperty().findAll();
|
||||
if (selectedIds.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final List<AssetPathEntity> onDevice =
|
||||
await PhotoManager.getAssetPathList(
|
||||
hasAll: true,
|
||||
filterOption: FilterOptionGroup(containsPathModified: true),
|
||||
);
|
||||
_log.info("Found ${onDevice.length} device albums");
|
||||
Set<String>? excludedAssets;
|
||||
if (excludedIds.isNotEmpty) {
|
||||
if (Platform.isIOS) {
|
||||
// iOS and Android device album working principle differ significantly
|
||||
// on iOS, an asset can be in multiple albums
|
||||
// on Android, an asset can only be in exactly one album (folder!) at the same time
|
||||
// thus, on Android, excluding an album can be done by ignoring that album
|
||||
// however, on iOS, it it necessary to load the assets from all excluded
|
||||
// albums and check every asset from any selected album against the set
|
||||
// of excluded assets
|
||||
excludedAssets = await _loadExcludedAssetIds(onDevice, excludedIds);
|
||||
_log.info("Found ${excludedAssets.length} assets to exclude");
|
||||
}
|
||||
// remove all excluded albums
|
||||
onDevice.removeWhere((e) => excludedIds.contains(e.id));
|
||||
_log.info(
|
||||
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
|
||||
);
|
||||
}
|
||||
final hasAll = selectedIds
|
||||
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
|
||||
.whereNotNull()
|
||||
.any((a) => a.isAll);
|
||||
if (hasAll) {
|
||||
if (Platform.isAndroid) {
|
||||
// remove the virtual "Recent" album and keep and individual albums
|
||||
// on Android, the virtual "Recent" `lastModified` value is always null
|
||||
onDevice.removeWhere((e) => e.isAll);
|
||||
_log.info("'Recents' is selected, keeping all individual albums");
|
||||
}
|
||||
} else {
|
||||
// keep only the explicitly selected albums
|
||||
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
|
||||
_log.info("'Recents' is not selected, keeping only selected albums");
|
||||
}
|
||||
changes =
|
||||
await _syncService.syncLocalAlbumAssetsToDb(onDevice, excludedAssets);
|
||||
_log.info("Syncing completed. Changes: $changes");
|
||||
} finally {
|
||||
_localCompleter.complete(changes);
|
||||
}
|
||||
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadExcludedAssetIds(
|
||||
List<AssetPathEntity> albums,
|
||||
List<String> excludedAlbumIds,
|
||||
) async {
|
||||
final Set<String> result = HashSet<String>();
|
||||
for (AssetPathEntity a in albums) {
|
||||
if (excludedAlbumIds.contains(a.id)) {
|
||||
final List<AssetEntity> assets =
|
||||
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
|
||||
result.addAll(assets.map((e) => e.id));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Checks remote albums (owned if `isShared` is false) for changes,
|
||||
/// updates the local database and returns `true` if there were any changes
|
||||
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
|
||||
if (!_remoteCompleter.isCompleted) {
|
||||
// guard against concurrent calls
|
||||
return _remoteCompleter.future;
|
||||
}
|
||||
_remoteCompleter = Completer();
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
bool changes = false;
|
||||
try {
|
||||
await _userService.refreshUsers();
|
||||
final List<AlbumResponseDto>? serverAlbums = await _apiService.albumApi
|
||||
.getAllAlbums(shared: isShared ? true : null);
|
||||
if (serverAlbums == null) {
|
||||
return false;
|
||||
}
|
||||
changes = await _syncService.syncRemoteAlbumsToDb(
|
||||
serverAlbums,
|
||||
isShared: isShared,
|
||||
loadDetails: (dto) async => dto.assetCount == dto.assets.length
|
||||
? dto
|
||||
: (await _apiService.albumApi.getAlbumInfo(dto.id)) ?? dto,
|
||||
);
|
||||
} finally {
|
||||
_remoteCompleter.complete(changes);
|
||||
}
|
||||
debugPrint("refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
Future<Album?> createAlbum(
|
||||
@@ -37,56 +171,51 @@ class AlbumService {
|
||||
Iterable<User> sharedUsers = const [],
|
||||
]) async {
|
||||
try {
|
||||
final dto = await _apiService.albumApi.createAlbum(
|
||||
AlbumResponseDto? remote = await _apiService.albumApi.createAlbum(
|
||||
CreateAlbumDto(
|
||||
albumName: albumName,
|
||||
assetIds: assets.map((asset) => asset.remoteId!).toList(),
|
||||
sharedWithUserIds: sharedUsers.map((e) => e.id).toList(),
|
||||
),
|
||||
);
|
||||
return dto != null ? Album.remote(dto) : null;
|
||||
if (remote != null) {
|
||||
Album album = await Album.remote(remote);
|
||||
await _db.writeTxn(() => _db.albums.store(album));
|
||||
return album;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error createSharedAlbum ${e.toString()}");
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Creates names like Untitled, Untitled (1), Untitled (2), ...
|
||||
*/
|
||||
String _getNextAlbumName(List<Album>? albums) {
|
||||
Future<String> _getNextAlbumName() async {
|
||||
const baseName = "Untitled";
|
||||
for (int round = 0;; round++) {
|
||||
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
|
||||
|
||||
if (albums != null) {
|
||||
for (int round = 0; round < albums.length; round++) {
|
||||
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
|
||||
|
||||
if (albums.where((a) => a.name == proposedName).isEmpty) {
|
||||
return proposedName;
|
||||
}
|
||||
if (null ==
|
||||
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
|
||||
return proposedName;
|
||||
}
|
||||
}
|
||||
return baseName;
|
||||
}
|
||||
|
||||
Future<Album?> createAlbumWithGeneratedName(
|
||||
Iterable<Asset> assets,
|
||||
) async {
|
||||
return createAlbum(
|
||||
_getNextAlbumName(await getAlbums(isShared: false)),
|
||||
await _getNextAlbumName(),
|
||||
assets,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Album?> getAlbumDetail(String albumId) async {
|
||||
try {
|
||||
final dto = await _apiService.albumApi.getAlbumInfo(albumId);
|
||||
return dto != null ? Album.remote(dto) : null;
|
||||
} catch (e) {
|
||||
debugPrint('Error [getAlbumDetail] ${e.toString()}');
|
||||
return null;
|
||||
}
|
||||
Future<Album?> getAlbumDetail(int albumId) {
|
||||
return _db.albums.get(albumId);
|
||||
}
|
||||
|
||||
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
|
||||
@@ -98,6 +227,10 @@ class AlbumService {
|
||||
album.remoteId!,
|
||||
AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()),
|
||||
);
|
||||
if (result != null && result.successfullyAdded > 0) {
|
||||
album.assets.addAll(assets);
|
||||
await _db.writeTxn(() => album.assets.save());
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
|
||||
@@ -110,26 +243,57 @@ class AlbumService {
|
||||
Album album,
|
||||
) async {
|
||||
try {
|
||||
var result = await _apiService.albumApi.addUsersToAlbum(
|
||||
final result = await _apiService.albumApi.addUsersToAlbum(
|
||||
album.remoteId!,
|
||||
AddUsersDto(sharedUserIds: sharedUserIds),
|
||||
);
|
||||
|
||||
return result != null;
|
||||
if (result != null) {
|
||||
album.sharedUsers
|
||||
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
|
||||
album.shared = result.shared;
|
||||
await _db.writeTxn(() async {
|
||||
await _db.albums.put(album);
|
||||
await album.sharedUsers.save();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> deleteAlbum(Album album) async {
|
||||
try {
|
||||
await _apiService.albumApi.deleteAlbum(album.remoteId!);
|
||||
final userId = Store.get(StoreKey.currentUser).isarId;
|
||||
if (album.owner.value?.isarId == userId) {
|
||||
await _apiService.albumApi.deleteAlbum(album.remoteId!);
|
||||
}
|
||||
if (album.shared) {
|
||||
final foreignAssets =
|
||||
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
final List<Album> albums =
|
||||
await _db.albums.filter().sharedEqualTo(true).findAll();
|
||||
final List<Asset> existing = [];
|
||||
for (Album a in albums) {
|
||||
existing.addAll(
|
||||
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
|
||||
);
|
||||
}
|
||||
final List<int> idsToRemove =
|
||||
_syncService.sharedAssetsToRemove(foreignAssets, existing);
|
||||
if (idsToRemove.isNotEmpty) {
|
||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
|
||||
}
|
||||
} else {
|
||||
await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint("Error deleteAlbum ${e.toString()}");
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> leaveAlbum(Album album) async {
|
||||
@@ -153,6 +317,8 @@ class AlbumService {
|
||||
assetIds: assets.map((e) => e.remoteId!).toList(growable: false),
|
||||
),
|
||||
);
|
||||
album.assets.removeAll(assets);
|
||||
await _db.writeTxn(() => album.assets.update(unlink: assets));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -173,6 +339,7 @@ class AlbumService {
|
||||
),
|
||||
);
|
||||
album.name = newAlbumTitle;
|
||||
await _db.writeTxn(() => _db.albums.put(album));
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,46 +1,23 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/services/json_cache.dart';
|
||||
|
||||
class BaseAlbumCacheService extends JsonCache<List<Album>> {
|
||||
BaseAlbumCacheService(super.cacheFileName);
|
||||
@Deprecated("only kept to remove its files after migration")
|
||||
class _BaseAlbumCacheService extends JsonCache<List<Album>> {
|
||||
_BaseAlbumCacheService(super.cacheFileName);
|
||||
|
||||
@override
|
||||
void put(List<Album> data) {
|
||||
putRawData(data.map((e) => e.toJson()).toList());
|
||||
}
|
||||
void put(List<Album> data) {}
|
||||
|
||||
@override
|
||||
Future<List<Album>?> get() async {
|
||||
try {
|
||||
final mapList = await readRawData() as List<dynamic>;
|
||||
|
||||
final responseData =
|
||||
mapList.map((e) => Album.fromJson(e)).whereNotNull().toList();
|
||||
|
||||
return responseData;
|
||||
} catch (e) {
|
||||
await invalidate();
|
||||
debugPrint(e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Future<List<Album>?> get() => Future.value(null);
|
||||
}
|
||||
|
||||
class AlbumCacheService extends BaseAlbumCacheService {
|
||||
@Deprecated("only kept to remove its files after migration")
|
||||
class AlbumCacheService extends _BaseAlbumCacheService {
|
||||
AlbumCacheService() : super("album_cache");
|
||||
}
|
||||
|
||||
class SharedAlbumCacheService extends BaseAlbumCacheService {
|
||||
@Deprecated("only kept to remove its files after migration")
|
||||
class SharedAlbumCacheService extends _BaseAlbumCacheService {
|
||||
SharedAlbumCacheService() : super("shared_album_cache");
|
||||
}
|
||||
|
||||
final albumCacheServiceProvider = Provider(
|
||||
(ref) => AlbumCacheService(),
|
||||
);
|
||||
|
||||
final sharedAlbumCacheServiceProvider = Provider(
|
||||
(ref) => SharedAlbumCacheService(),
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(albumProvider);
|
||||
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||
final albumService = ref.watch(albumServiceProvider);
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
|
||||
class AlbumThumbnailCard extends StatelessWidget {
|
||||
final Function()? onTap;
|
||||
|
||||
/// Whether or not to show the owner of the album (or "Owned")
|
||||
/// in the subtitle of the album
|
||||
final bool showOwner;
|
||||
|
||||
const AlbumThumbnailCard({
|
||||
Key? key,
|
||||
required this.album,
|
||||
this.onTap,
|
||||
this.showOwner = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final Album album;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -42,19 +43,47 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
buildAlbumThumbnail() {
|
||||
return CachedNetworkImage(
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageUrl: getAlbumThumbnailUrl(
|
||||
album,
|
||||
type: ThumbnailFormat.JPEG,
|
||||
buildAlbumThumbnail() => ImmichImage(
|
||||
album.thumbnail.value,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
);
|
||||
|
||||
buildAlbumTextRow() {
|
||||
// Add the owner name to the subtitle
|
||||
String? owner;
|
||||
if (showOwner) {
|
||||
if (album.ownerId == Store.get(StoreKey.currentUser).id) {
|
||||
owner = 'album_thumbnail_owned'.tr();
|
||||
} else if (album.ownerName != null) {
|
||||
owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]);
|
||||
}
|
||||
}
|
||||
|
||||
return RichText(
|
||||
overflow: TextOverflow.fade,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
.tr(args: ['${album.assetCount}'])
|
||||
: 'album_thumbnail_card_items'
|
||||
.tr(args: ['${album.assetCount}']),
|
||||
style: TextStyle(
|
||||
fontFamily: 'WorkSans',
|
||||
fontSize: 12,
|
||||
color: isDarkMode ? Colors.white : Colors.black,
|
||||
),
|
||||
),
|
||||
if (owner != null) const TextSpan(text: ' · '),
|
||||
if (owner != null)
|
||||
TextSpan(
|
||||
text: owner,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
cacheKey:
|
||||
getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +101,7 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
height: cardSize,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: album.albumThumbnailAssetId == null
|
||||
child: album.thumbnail.value == null
|
||||
? buildEmptyThumbnail()
|
||||
: buildAlbumThumbnail(),
|
||||
),
|
||||
@@ -83,32 +112,16 @@ class AlbumThumbnailCard extends StatelessWidget {
|
||||
width: cardSize,
|
||||
child: Text(
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDarkMode
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
: 'album_thumbnail_card_items',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(args: ['${album.assetCount}']),
|
||||
if (album.shared)
|
||||
const Text(
|
||||
'album_thumbnail_card_shared',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr()
|
||||
],
|
||||
)
|
||||
buildAlbumTextRow(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,10 +2,9 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -21,7 +20,6 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
var cardSize = 68.0;
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
@@ -50,8 +48,12 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
album,
|
||||
type: ThumbnailFormat.JPEG,
|
||||
),
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
httpHeaders: {
|
||||
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
|
||||
},
|
||||
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +70,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: album.albumThumbnailAssetId == null
|
||||
child: album.thumbnail.value == null
|
||||
? buildEmptyThumbnail()
|
||||
: buildAlbumThumbnail(),
|
||||
),
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
@@ -35,19 +34,18 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
void onDeleteAlbumPressed() async {
|
||||
ImmichLoadingOverlayController.appLoader.show();
|
||||
|
||||
bool isSuccess = await ref.watch(albumServiceProvider).deleteAlbum(album);
|
||||
|
||||
if (isSuccess) {
|
||||
if (album.shared) {
|
||||
ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
} else {
|
||||
ref.watch(albumProvider.notifier).deleteAlbum(album);
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
|
||||
}
|
||||
final bool success;
|
||||
if (album.shared) {
|
||||
success =
|
||||
await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [SharingRoute()]));
|
||||
} else {
|
||||
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
|
||||
AutoRouter.of(context)
|
||||
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
|
||||
}
|
||||
if (!success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "album_viewer_appbar_share_err_delete".tr(),
|
||||
@@ -208,11 +206,12 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
: null,
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
splashRadius: 25,
|
||||
onPressed: buildBottomSheet,
|
||||
icon: const Icon(Icons.more_horiz_rounded),
|
||||
),
|
||||
if (album.isRemote)
|
||||
IconButton(
|
||||
splashRadius: 25,
|
||||
onPressed: buildBottomSheet,
|
||||
icon: const Icon(Icons.more_horiz_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
@@ -22,7 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||
final selectedAssetsInAlbumViewer =
|
||||
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
|
||||
final isMultiSelectionEnable =
|
||||
@@ -88,7 +86,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
||||
bottom: 5,
|
||||
child: Icon(
|
||||
asset.isRemote
|
||||
? (deviceId == asset.deviceId
|
||||
? (asset.isLocal
|
||||
? Icons.cloud_done_outlined
|
||||
: Icons.cloud_outlined)
|
||||
: Icons.cloud_off_outlined,
|
||||
|
||||
@@ -25,7 +25,7 @@ import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegat
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
|
||||
class AlbumViewerPage extends HookConsumerWidget {
|
||||
final String albumId;
|
||||
final int albumId;
|
||||
|
||||
const AlbumViewerPage({Key? key, required this.albumId}) : super(key: key);
|
||||
|
||||
@@ -101,7 +101,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
Widget buildTitle(Album album) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
|
||||
child: userId == album.ownerId
|
||||
child: userId == album.ownerId && album.isRemote
|
||||
? AlbumViewerEditableTitle(
|
||||
album: album,
|
||||
titleFocusNode: titleFocusNode,
|
||||
@@ -122,9 +122,10 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
Widget buildAlbumDateRange(Album album) {
|
||||
final DateTime startDate = album.assets.first.fileCreatedAt;
|
||||
final DateTime endDate = album.assets.last.fileCreatedAt; //Need default.
|
||||
final String startDateText =
|
||||
(startDate.year == endDate.year ? DateFormat.MMMd() : DateFormat.yMMMd())
|
||||
.format(startDate);
|
||||
final String startDateText = (startDate.year == endDate.year
|
||||
? DateFormat.MMMd()
|
||||
: DateFormat.yMMMd())
|
||||
.format(startDate);
|
||||
final String endDateText = DateFormat.yMMMd().format(endDate);
|
||||
|
||||
return Padding(
|
||||
@@ -188,7 +189,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
final bool showStorageIndicator =
|
||||
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
||||
|
||||
if (album.assets.isNotEmpty) {
|
||||
if (album.sortedAssets.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 10.0),
|
||||
sliver: SliverGrid(
|
||||
@@ -201,8 +202,8 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return AlbumViewerThumbnail(
|
||||
asset: album.assets[index],
|
||||
assetList: album.assets,
|
||||
asset: album.sortedAssets[index],
|
||||
assetList: album.sortedAssets,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
);
|
||||
},
|
||||
@@ -267,17 +268,18 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
buildHeader(album),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: ImmichSliverPersistentAppBarDelegate(
|
||||
minHeight: 50,
|
||||
maxHeight: 50,
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: buildControlButton(album),
|
||||
if (album.isRemote)
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: ImmichSliverPersistentAppBarDelegate(
|
||||
minHeight: 50,
|
||||
maxHeight: 50,
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: buildControlButton(album),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverSafeArea(
|
||||
sliver: buildImageGrid(album),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
|
||||
class LibraryPage extends HookConsumerWidget {
|
||||
const LibraryPage({Key? key}) : super(key: key);
|
||||
@@ -16,6 +18,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(albumProvider);
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -40,13 +43,17 @@ class LibraryPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final selectedAlbumSortOrder = useState(0);
|
||||
final selectedAlbumSortOrder = useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
|
||||
|
||||
List<Album> sortedAlbums() {
|
||||
if (selectedAlbumSortOrder.value == 0) {
|
||||
return albums.sortedBy((album) => album.createdAt).reversed.toList();
|
||||
return albums
|
||||
.where((a) => a.isRemote)
|
||||
.sortedBy((album) => album.createdAt)
|
||||
.reversed
|
||||
.toList();
|
||||
}
|
||||
return albums.sortedBy((album) => album.name);
|
||||
return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
|
||||
}
|
||||
|
||||
Widget buildSortButton() {
|
||||
@@ -87,6 +94,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
},
|
||||
onSelected: (int value) {
|
||||
selectedAlbumSortOrder.value = value;
|
||||
settings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, value);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -194,6 +202,8 @@ class LibraryPage extends HookConsumerWidget {
|
||||
|
||||
final sorted = sortedAlbums();
|
||||
|
||||
final local = albums.where((a) => a.isLocal).toList();
|
||||
|
||||
return Scaffold(
|
||||
appBar: buildAppBar(),
|
||||
body: CustomScrollView(
|
||||
@@ -270,6 +280,47 @@ class LibraryPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 12.0,
|
||||
left: 12.0,
|
||||
right: 12.0,
|
||||
bottom: 20.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'library_page_device_albums',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 250,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: .7,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: local.length,
|
||||
(context, index) => AlbumThumbnailCard(
|
||||
album: local[index],
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
AlbumViewerRoute(
|
||||
albumId: local[index].id,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart' as store;
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
|
||||
class SharingPage extends HookConsumerWidget {
|
||||
const SharingPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
final userId = store.Store.get(store.StoreKey.currentUser).id;
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -28,37 +28,77 @@ class SharingPage extends HookConsumerWidget {
|
||||
[],
|
||||
);
|
||||
|
||||
buildAlbumGrid() {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 250,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: .7,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return AlbumThumbnailCard(
|
||||
album: sharedAlbums[index],
|
||||
showOwner: true,
|
||||
onTap: () {
|
||||
AutoRouter.of(context)
|
||||
.push(AlbumViewerRoute(albumId: sharedAlbums[index].id));
|
||||
},
|
||||
);
|
||||
},
|
||||
childCount: sharedAlbums.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildAlbumList() {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
final album = sharedAlbums[index];
|
||||
final isOwner = album.ownerId == userId;
|
||||
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
child: ImmichImage(
|
||||
album.thumbnail.value,
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: getAlbumThumbnailUrl(album),
|
||||
cacheKey: getAlbumThumbNailCacheKey(album),
|
||||
httpHeaders: {
|
||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||
},
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
sharedAlbums[index].name,
|
||||
album.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDarkMode
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
subtitle: isOwner
|
||||
? const Text(
|
||||
'Owned',
|
||||
style: TextStyle(
|
||||
fontSize: 12.0,
|
||||
),
|
||||
)
|
||||
: album.ownerName != null
|
||||
? Text(
|
||||
'Shared by ${album.ownerName!}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
AutoRouter.of(context)
|
||||
.push(AlbumViewerRoute(albumId: sharedAlbums[index].id));
|
||||
@@ -134,9 +174,19 @@ class SharingPage extends HookConsumerWidget {
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
sharedAlbums.isNotEmpty
|
||||
? buildAlbumList()
|
||||
: buildEmptyListIndication()
|
||||
SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (sharedAlbums.isEmpty) {
|
||||
return buildEmptyListIndication();
|
||||
}
|
||||
|
||||
if (constraints.crossAxisExtent < 600) {
|
||||
return buildAlbumList();
|
||||
} else {
|
||||
return buildAlbumGrid();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
final renderListProvider = FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final layout = AssetGridLayoutParameters(
|
||||
settings.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
settings.getSetting(AppSettingsEnum.dynamicLayout),
|
||||
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
|
||||
);
|
||||
|
||||
return RenderList.fromAssets(assets, layout);
|
||||
});
|
||||
@@ -14,10 +14,14 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
const ExifBottomSheet({Key? key, required this.assetDetail})
|
||||
: super(key: key);
|
||||
|
||||
bool get showMap => assetDetail.latitude != null && assetDetail.longitude != null;
|
||||
bool get showMap =>
|
||||
assetDetail.exifInfo?.latitude != null &&
|
||||
assetDetail.exifInfo?.longitude != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final ExifInfo? exifInfo = assetDetail.exifInfo;
|
||||
|
||||
buildMap() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
@@ -33,8 +37,8 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
options: MapOptions(
|
||||
interactiveFlags: InteractiveFlag.none,
|
||||
center: LatLng(
|
||||
assetDetail.latitude ?? 0,
|
||||
assetDetail.longitude ?? 0,
|
||||
exifInfo?.latitude ?? 0,
|
||||
exifInfo?.longitude ?? 0,
|
||||
),
|
||||
zoom: 16.0,
|
||||
),
|
||||
@@ -55,8 +59,8 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: LatLng(
|
||||
assetDetail.latitude ?? 0,
|
||||
assetDetail.longitude ?? 0,
|
||||
exifInfo?.latitude ?? 0,
|
||||
exifInfo?.longitude ?? 0,
|
||||
),
|
||||
builder: (ctx) => const Image(
|
||||
image: AssetImage('assets/location-pin.png'),
|
||||
@@ -74,8 +78,6 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
|
||||
final textColor = Theme.of(context).primaryColor;
|
||||
|
||||
ExifInfo? exifInfo = assetDetail.exifInfo;
|
||||
|
||||
buildLocationText() {
|
||||
return Text(
|
||||
"${exifInfo?.city}, ${exifInfo?.state}",
|
||||
@@ -134,7 +136,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
exifInfo.state != null)
|
||||
buildLocationText(),
|
||||
Text(
|
||||
"${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}",
|
||||
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
|
||||
style: const TextStyle(fontSize: 12),
|
||||
)
|
||||
],
|
||||
|
||||
@@ -4,20 +4,20 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/services/asset.service.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
||||
@@ -38,8 +38,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
super.key,
|
||||
required this.assetList,
|
||||
required this.asset,
|
||||
}) : controller =
|
||||
PageController(initialPage: assetList.indexOf(asset));
|
||||
}) : controller = PageController(initialPage: assetList.indexOf(asset));
|
||||
|
||||
Asset? assetDetail;
|
||||
|
||||
@@ -47,7 +46,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final Box<dynamic> box = Hive.box(userInfoBox);
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
|
||||
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
|
||||
@@ -57,7 +55,16 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
final isPlayingMotionVideo = useState(false);
|
||||
final isPlayingVideo = useState(false);
|
||||
late Offset localPosition;
|
||||
final authToken = 'Bearer ${box.get(accessTokenKey)}';
|
||||
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
|
||||
|
||||
showAppBar.addListener(() {
|
||||
// Change to and from immersive mode, hiding navigation and app bar
|
||||
if (showAppBar.value) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} else {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -75,15 +82,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
ref.watch(favoriteProvider.notifier).toggleFavorite(asset);
|
||||
}
|
||||
|
||||
getAssetExif() async {
|
||||
if (assetList[indexOfAsset.value].isRemote) {
|
||||
assetDetail = await ref
|
||||
.watch(assetServiceProvider)
|
||||
.getAssetById(assetList[indexOfAsset.value].id);
|
||||
} else {
|
||||
// TODO local exif parsing?
|
||||
assetDetail = assetList[indexOfAsset.value];
|
||||
}
|
||||
void getAssetExif() async {
|
||||
assetDetail = assetList[indexOfAsset.value];
|
||||
assetDetail = await ref
|
||||
.watch(assetServiceProvider)
|
||||
.loadExif(assetList[indexOfAsset.value]);
|
||||
}
|
||||
|
||||
/// Thumbnail image of a remote asset. Required asset.isRemote
|
||||
@@ -127,16 +130,23 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
|
||||
/// Original (large) image of a local asset. Required asset.isLocal
|
||||
ImageProvider localImageProvider(Asset asset) {
|
||||
return AssetEntityImageProvider(asset.local!);
|
||||
return AssetEntityImageProvider(
|
||||
isOriginal: true,
|
||||
asset.local!,
|
||||
);
|
||||
}
|
||||
|
||||
void precacheNextImage(int index) {
|
||||
if (index < assetList.length && index > 0) {
|
||||
if (index < assetList.length && index >= 0) {
|
||||
final asset = assetList[index];
|
||||
|
||||
if (asset.isLocal) {
|
||||
// Preload the local asset
|
||||
precacheImage(localImageProvider(asset), context);
|
||||
} else {
|
||||
onError(Object exception, StackTrace? stackTrace) {
|
||||
// swallow error silently
|
||||
}
|
||||
// Probably load WEBP either way
|
||||
precacheImage(
|
||||
remoteThumbnailImageProvider(
|
||||
@@ -144,6 +154,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
if (isLoadPreview.value) {
|
||||
// Precache the JPEG thumbnail
|
||||
@@ -153,6 +164,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
if (isLoadOriginal.value) {
|
||||
@@ -160,6 +172,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
precacheImage(
|
||||
originalImageProvider(asset),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -292,140 +305,173 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
scaleStateChangedCallback: (state) {
|
||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||
showAppBar.value = !isZoomed.value;
|
||||
},
|
||||
pageController: controller,
|
||||
scrollPhysics: isZoomed.value
|
||||
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
||||
: (Platform.isIOS
|
||||
? const BouncingScrollPhysics() // Use bouncing physics for iOS
|
||||
: const ClampingScrollPhysics() // Use heavy physics for Android
|
||||
),
|
||||
itemCount: assetList.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) {
|
||||
// Precache image
|
||||
if (indexOfAsset.value < value) {
|
||||
// Moving forwards, so precache the next asset
|
||||
precacheNextImage(value + 1);
|
||||
} else {
|
||||
// Moving backwards, so precache previous asset
|
||||
precacheNextImage(value - 1);
|
||||
}
|
||||
indexOfAsset.value = value;
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
loadingBuilder: isLoadPreview.value
|
||||
? (context, event) {
|
||||
final asset = assetList[indexOfAsset.value];
|
||||
if (!asset.isLocal) {
|
||||
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
|
||||
// Three-Stage Loading (WEBP -> JPEG -> Original)
|
||||
final webPThumbnail = CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
asset,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
progressIndicatorBuilder: (_, __, ___) => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
body: WillPopScope(
|
||||
onWillPop: () async {
|
||||
// Change immersive mode back to normal "edgeToEdge" mode
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
return true;
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
scaleStateChangedCallback: (state) {
|
||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||
showAppBar.value = !isZoomed.value;
|
||||
},
|
||||
pageController: controller,
|
||||
scrollPhysics: isZoomed.value
|
||||
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
||||
: (Platform.isIOS
|
||||
? const BouncingScrollPhysics() // Use bouncing physics for iOS
|
||||
: const ClampingScrollPhysics() // Use heavy physics for Android
|
||||
),
|
||||
itemCount: assetList.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) {
|
||||
// Precache image
|
||||
if (indexOfAsset.value < value) {
|
||||
// Moving forwards, so precache the next asset
|
||||
precacheNextImage(value + 1);
|
||||
} else {
|
||||
// Moving backwards, so precache previous asset
|
||||
precacheNextImage(value - 1);
|
||||
}
|
||||
indexOfAsset.value = value;
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
loadingBuilder: isLoadPreview.value
|
||||
? (context, event) {
|
||||
final asset = assetList[indexOfAsset.value];
|
||||
if (!asset.isLocal) {
|
||||
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
|
||||
// Three-Stage Loading (WEBP -> JPEG -> Original)
|
||||
final webPThumbnail = CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
asset,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
progressIndicatorBuilder: (_, __, ___) =>
|
||||
const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fit: BoxFit.contain,
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
);
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
asset,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
fit: BoxFit.contain,
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
placeholder: (_, __) => webPThumbnail,
|
||||
if (isLoadOriginal.value) {
|
||||
// loading the preview in the loadingBuilder only
|
||||
// makes sense if the original is loaded in the builder
|
||||
return CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
asset,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
fit: BoxFit.contain,
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
placeholder: (_, __) => webPThumbnail,
|
||||
errorWidget: (_, __, ___) => webPThumbnail,
|
||||
);
|
||||
} else {
|
||||
return webPThumbnail;
|
||||
}
|
||||
} else {
|
||||
return Image(
|
||||
image: localThumbnailImageProvider(asset),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
builder: (context, index) {
|
||||
getAssetExif();
|
||||
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
|
||||
// Show photo
|
||||
final ImageProvider provider;
|
||||
if (assetList[index].isLocal) {
|
||||
provider = localImageProvider(assetList[index]);
|
||||
} else {
|
||||
if (isLoadOriginal.value) {
|
||||
provider = originalImageProvider(assetList[index]);
|
||||
} else if (isLoadPreview.value) {
|
||||
provider = remoteThumbnailImageProvider(
|
||||
assetList[index],
|
||||
api.ThumbnailFormat.JPEG,
|
||||
);
|
||||
} else {
|
||||
return Image(
|
||||
image: localThumbnailImageProvider(asset),
|
||||
fit: BoxFit.contain,
|
||||
provider = remoteThumbnailImageProvider(
|
||||
assetList[index],
|
||||
api.ThumbnailFormat.WEBP,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
builder: (context, index) {
|
||||
getAssetExif();
|
||||
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
|
||||
// Show photo
|
||||
final ImageProvider provider;
|
||||
if (assetList[index].isLocal) {
|
||||
provider = localImageProvider(assetList[index]);
|
||||
} else {
|
||||
if (isLoadOriginal.value) {
|
||||
provider = originalImageProvider(assetList[index]);
|
||||
} else {
|
||||
provider = remoteThumbnailImageProvider(
|
||||
assetList[index],
|
||||
api.ThumbnailFormat.JPEG,
|
||||
);
|
||||
}
|
||||
}
|
||||
return PhotoViewGalleryPageOptions(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition = details.localPosition,
|
||||
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
|
||||
onTapDown: (_, __, ___) =>
|
||||
showAppBar.value = !showAppBar.value,
|
||||
imageProvider: provider,
|
||||
heroAttributes:
|
||||
PhotoViewHeroAttributes(tag: assetList[index].id),
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
);
|
||||
} else {
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition = details.localPosition,
|
||||
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
|
||||
heroAttributes:
|
||||
PhotoViewHeroAttributes(tag: assetList[index].id),
|
||||
maxScale: 1.0,
|
||||
minScale: 1.0,
|
||||
child: SafeArea(
|
||||
child: VideoViewerPage(
|
||||
onPlaying: () => isPlayingVideo.value = true,
|
||||
onPaused: () => isPlayingVideo.value = false,
|
||||
asset: assetList[index],
|
||||
isMotionVideo: isPlayingMotionVideo.value,
|
||||
onVideoEnded: () {
|
||||
if (isPlayingMotionVideo.value) {
|
||||
isPlayingMotionVideo.value = false;
|
||||
}
|
||||
},
|
||||
return PhotoViewGalleryPageOptions(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition = details.localPosition,
|
||||
onDragUpdate: (_, details, __) =>
|
||||
handleSwipeUpDown(details),
|
||||
onTapDown: (_, __, ___) =>
|
||||
showAppBar.value = !showAppBar.value,
|
||||
imageProvider: provider,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: assetList[index].id,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: buildAppBar(),
|
||||
),
|
||||
],
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
||||
assetList[indexOfAsset.value],
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition = details.localPosition,
|
||||
onDragUpdate: (_, details, __) =>
|
||||
handleSwipeUpDown(details),
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: assetList[index].id,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
maxScale: 1.0,
|
||||
minScale: 1.0,
|
||||
child: SafeArea(
|
||||
child: VideoViewerPage(
|
||||
onPlaying: () => isPlayingVideo.value = true,
|
||||
onPaused: () => isPlayingVideo.value = false,
|
||||
asset: assetList[index],
|
||||
isMotionVideo: isPlayingMotionVideo.value,
|
||||
onVideoEnded: () {
|
||||
if (isPlayingMotionVideo.value) {
|
||||
isPlayingMotionVideo.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: buildAppBar(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
@@ -54,17 +53,15 @@ class VideoViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
final downloadAssetStatus =
|
||||
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
final box = Hive.box(userInfoBox);
|
||||
final String jwtToken = box.get(accessTokenKey);
|
||||
final String videoUrl = isMotionVideo
|
||||
? '${box.get(serverEndpointKey)}/asset/file/${asset.livePhotoVideoId}'
|
||||
: '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}';
|
||||
? '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.livePhotoVideoId}'
|
||||
: '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}';
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
VideoThumbnailPlayer(
|
||||
url: videoUrl,
|
||||
jwtToken: jwtToken,
|
||||
jwtToken: Store.get(StoreKey.accessToken),
|
||||
isMotionVideo: isMotionVideo,
|
||||
onVideoEnded: onVideoEnded,
|
||||
onPaused: onPaused,
|
||||
|
||||
@@ -4,21 +4,22 @@ import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui' show DartPluginRegistrant, IsolateNameServer, PluginUtilities;
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/main.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/localization.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider_ios/path_provider_ios.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
@@ -51,10 +52,6 @@ class BackgroundService {
|
||||
_Throttle(_updateProgress, notifyInterval);
|
||||
late final _Throttle _throttledDetailNotify =
|
||||
_Throttle(_updateDetailProgress, notifyInterval);
|
||||
Completer<bool> _hasAccessCompleter = Completer();
|
||||
late Future<bool> _hasAccess = _hasAccessCompleter.future;
|
||||
|
||||
Future<bool> get hasAccess => _hasAccess;
|
||||
|
||||
bool get isBackgroundInitialized {
|
||||
return _isBackgroundInitialized;
|
||||
@@ -194,11 +191,6 @@ class BackgroundService {
|
||||
debugPrint("WARNING: [acquireLock] called more than once");
|
||||
return true;
|
||||
}
|
||||
if (_hasAccessCompleter.isCompleted) {
|
||||
debugPrint("WARNING: [acquireLock] _hasAccessCompleter is completed");
|
||||
_hasAccessCompleter = Completer();
|
||||
_hasAccess = _hasAccessCompleter.future;
|
||||
}
|
||||
final int lockTime = Timeline.now;
|
||||
_wantsLockTime = lockTime;
|
||||
final ReceivePort rp = ReceivePort(_portNameLock);
|
||||
@@ -217,7 +209,6 @@ class BackgroundService {
|
||||
}
|
||||
_hasLock = true;
|
||||
rp.listen(_heartbeatListener);
|
||||
_hasAccessCompleter.complete(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -267,8 +258,6 @@ class BackgroundService {
|
||||
void releaseLock() {
|
||||
_wantsLockTime = 0;
|
||||
if (_hasLock) {
|
||||
_hasAccessCompleter = Completer();
|
||||
_hasAccess = _hasAccessCompleter.future;
|
||||
IsolateNameServer.removePortNameMapping(_portNameLock);
|
||||
_waitingIsolate?.send(true);
|
||||
_waitingIsolate = null;
|
||||
@@ -325,7 +314,6 @@ class BackgroundService {
|
||||
debugPrint(error.toString());
|
||||
return false;
|
||||
} finally {
|
||||
await Hive.close();
|
||||
releaseLock();
|
||||
}
|
||||
case "systemStop":
|
||||
@@ -339,29 +327,16 @@ class BackgroundService {
|
||||
}
|
||||
|
||||
Future<bool> _onAssetsChanged() async {
|
||||
await Hive.initFlutter();
|
||||
final Isar db = await loadDb();
|
||||
|
||||
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||
Hive.registerAdapter(HiveDuplicatedAssetsAdapter());
|
||||
|
||||
await Future.wait([
|
||||
Hive.openBox(userInfoBox),
|
||||
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
|
||||
Hive.openBox(userSettingInfoBox),
|
||||
Hive.openBox(backgroundBackupInfoBox),
|
||||
Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
|
||||
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
|
||||
]);
|
||||
ApiService apiService = ApiService();
|
||||
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
|
||||
BackupService backupService = BackupService(apiService);
|
||||
apiService.setAccessToken(Store.get(StoreKey.accessToken));
|
||||
BackupService backupService = BackupService(apiService, db);
|
||||
AppSettingsService settingsService = AppSettingsService();
|
||||
|
||||
final Box<HiveBackupAlbums> box =
|
||||
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||
if (backupAlbumInfo == null) {
|
||||
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
|
||||
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
|
||||
if (selectedAlbums.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -371,18 +346,37 @@ class BackgroundService {
|
||||
final bool backupOk = await _runBackup(
|
||||
backupService,
|
||||
settingsService,
|
||||
backupAlbumInfo,
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
);
|
||||
if (backupOk) {
|
||||
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
|
||||
await box.put(
|
||||
backupInfoKey,
|
||||
backupAlbumInfo,
|
||||
);
|
||||
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
|
||||
null) {
|
||||
Hive.box(backgroundBackupInfoBox)
|
||||
.put(backupFailedSince, DateTime.now());
|
||||
await Store.delete(StoreKey.backupFailedSince);
|
||||
final backupAlbums = [...selectedAlbums, ...excludedAlbums];
|
||||
backupAlbums.sortBy((e) => e.id);
|
||||
db.writeTxnSync(() {
|
||||
final dbAlbums = db.backupAlbums.where().sortById().findAllSync();
|
||||
final List<int> toDelete = [];
|
||||
final List<BackupAlbum> toUpsert = [];
|
||||
// stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state
|
||||
diffSortedListsSync(
|
||||
dbAlbums,
|
||||
backupAlbums,
|
||||
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
|
||||
both: (BackupAlbum a, BackupAlbum b) {
|
||||
a.lastBackup = a.lastBackup.isAfter(b.lastBackup)
|
||||
? a.lastBackup
|
||||
: b.lastBackup;
|
||||
toUpsert.add(a);
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (BackupAlbum a) => toUpsert.add(a),
|
||||
onlySecond: (BackupAlbum b) => toDelete.add(b.isarId),
|
||||
);
|
||||
db.backupAlbums.deleteAllSync(toDelete);
|
||||
db.backupAlbums.putAllSync(toUpsert);
|
||||
});
|
||||
} else if (Store.tryGet(StoreKey.backupFailedSince) == null) {
|
||||
Store.put(StoreKey.backupFailedSince, DateTime.now());
|
||||
return false;
|
||||
}
|
||||
// Android should check for new assets added while performing backup
|
||||
@@ -395,7 +389,8 @@ class BackgroundService {
|
||||
Future<bool> _runBackup(
|
||||
BackupService backupService,
|
||||
AppSettingsService settingsService,
|
||||
HiveBackupAlbums backupAlbumInfo,
|
||||
List<BackupAlbum> selectedAlbums,
|
||||
List<BackupAlbum> excludedAlbums,
|
||||
) async {
|
||||
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
|
||||
final bool notifyTotalProgress = settingsService
|
||||
@@ -407,8 +402,10 @@ class BackgroundService {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<AssetEntity> toUpload =
|
||||
await backupService.buildUploadCandidates(backupAlbumInfo);
|
||||
List<AssetEntity> toUpload = await backupService.buildUploadCandidates(
|
||||
selectedAlbums,
|
||||
excludedAlbums,
|
||||
);
|
||||
|
||||
try {
|
||||
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
|
||||
@@ -520,8 +517,7 @@ class BackgroundService {
|
||||
} else if (value == 5) {
|
||||
return false;
|
||||
}
|
||||
final DateTime? failedSince =
|
||||
Hive.box(backgroundBackupInfoBox).get(backupFailedSince);
|
||||
final DateTime? failedSince = Store.tryGet(StoreKey.backupFailedSince);
|
||||
if (failedSince == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
22
mobile/lib/modules/backup/models/backup_album.model.dart
Normal file
22
mobile/lib/modules/backup/models/backup_album.model.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
part 'backup_album.model.g.dart';
|
||||
|
||||
@Collection(inheritance: false)
|
||||
class BackupAlbum {
|
||||
String id;
|
||||
DateTime lastBackup;
|
||||
@Enumerated(EnumType.ordinal)
|
||||
BackupSelection selection;
|
||||
|
||||
BackupAlbum(this.id, this.lastBackup, this.selection);
|
||||
|
||||
Id get isarId => fastHash(id);
|
||||
}
|
||||
|
||||
enum BackupSelection {
|
||||
none,
|
||||
select,
|
||||
exclude;
|
||||
}
|
||||
653
mobile/lib/modules/backup/models/backup_album.model.g.dart
Normal file
653
mobile/lib/modules/backup/models/backup_album.model.g.dart
Normal file
@@ -0,0 +1,653 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'backup_album.model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// IsarCollectionGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters
|
||||
|
||||
extension GetBackupAlbumCollection on Isar {
|
||||
IsarCollection<BackupAlbum> get backupAlbums => this.collection();
|
||||
}
|
||||
|
||||
const BackupAlbumSchema = CollectionSchema(
|
||||
name: r'BackupAlbum',
|
||||
id: 8308487201128361847,
|
||||
properties: {
|
||||
r'id': PropertySchema(
|
||||
id: 0,
|
||||
name: r'id',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'lastBackup': PropertySchema(
|
||||
id: 1,
|
||||
name: r'lastBackup',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'selection': PropertySchema(
|
||||
id: 2,
|
||||
name: r'selection',
|
||||
type: IsarType.byte,
|
||||
enumMap: _BackupAlbumselectionEnumValueMap,
|
||||
)
|
||||
},
|
||||
estimateSize: _backupAlbumEstimateSize,
|
||||
serialize: _backupAlbumSerialize,
|
||||
deserialize: _backupAlbumDeserialize,
|
||||
deserializeProp: _backupAlbumDeserializeProp,
|
||||
idName: r'isarId',
|
||||
indexes: {},
|
||||
links: {},
|
||||
embeddedSchemas: {},
|
||||
getId: _backupAlbumGetId,
|
||||
getLinks: _backupAlbumGetLinks,
|
||||
attach: _backupAlbumAttach,
|
||||
version: '3.0.5',
|
||||
);
|
||||
|
||||
int _backupAlbumEstimateSize(
|
||||
BackupAlbum object,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
var bytesCount = offsets.last;
|
||||
bytesCount += 3 + object.id.length * 3;
|
||||
return bytesCount;
|
||||
}
|
||||
|
||||
void _backupAlbumSerialize(
|
||||
BackupAlbum object,
|
||||
IsarWriter writer,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
writer.writeString(offsets[0], object.id);
|
||||
writer.writeDateTime(offsets[1], object.lastBackup);
|
||||
writer.writeByte(offsets[2], object.selection.index);
|
||||
}
|
||||
|
||||
BackupAlbum _backupAlbumDeserialize(
|
||||
Id id,
|
||||
IsarReader reader,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
final object = BackupAlbum(
|
||||
reader.readString(offsets[0]),
|
||||
reader.readDateTime(offsets[1]),
|
||||
_BackupAlbumselectionValueEnumMap[reader.readByteOrNull(offsets[2])] ??
|
||||
BackupSelection.none,
|
||||
);
|
||||
return object;
|
||||
}
|
||||
|
||||
P _backupAlbumDeserializeProp<P>(
|
||||
IsarReader reader,
|
||||
int propertyId,
|
||||
int offset,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
switch (propertyId) {
|
||||
case 0:
|
||||
return (reader.readString(offset)) as P;
|
||||
case 1:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 2:
|
||||
return (_BackupAlbumselectionValueEnumMap[
|
||||
reader.readByteOrNull(offset)] ??
|
||||
BackupSelection.none) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
}
|
||||
}
|
||||
|
||||
const _BackupAlbumselectionEnumValueMap = {
|
||||
'none': 0,
|
||||
'select': 1,
|
||||
'exclude': 2,
|
||||
};
|
||||
const _BackupAlbumselectionValueEnumMap = {
|
||||
0: BackupSelection.none,
|
||||
1: BackupSelection.select,
|
||||
2: BackupSelection.exclude,
|
||||
};
|
||||
|
||||
Id _backupAlbumGetId(BackupAlbum object) {
|
||||
return object.isarId;
|
||||
}
|
||||
|
||||
List<IsarLinkBase<dynamic>> _backupAlbumGetLinks(BackupAlbum object) {
|
||||
return [];
|
||||
}
|
||||
|
||||
void _backupAlbumAttach(
|
||||
IsarCollection<dynamic> col, Id id, BackupAlbum object) {}
|
||||
|
||||
extension BackupAlbumQueryWhereSort
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QWhere> {
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhere> anyIsarId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(const IdWhereClause.any());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension BackupAlbumQueryWhere
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QWhereClause> {
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdEqualTo(
|
||||
Id isarId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IdWhereClause.between(
|
||||
lower: isarId,
|
||||
upper: isarId,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdNotEqualTo(
|
||||
Id isarId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
|
||||
);
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdGreaterThan(
|
||||
Id isarId,
|
||||
{bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: isarId, includeLower: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdLessThan(
|
||||
Id isarId,
|
||||
{bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: isarId, includeUpper: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterWhereClause> isarIdBetween(
|
||||
Id lowerIsarId,
|
||||
Id upperIsarId, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IdWhereClause.between(
|
||||
lower: lowerIsarId,
|
||||
includeLower: includeLower,
|
||||
upper: upperIsarId,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension BackupAlbumQueryFilter
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idEqualTo(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idGreaterThan(
|
||||
String value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idLessThan(
|
||||
String value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idBetween(
|
||||
String lower,
|
||||
String upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'id',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idStartsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.startsWith(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idEndsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.endsWith(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idContains(
|
||||
String value,
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.contains(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idMatches(
|
||||
String pattern,
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.matches(
|
||||
property: r'id',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'id',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> idIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
property: r'id',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> isarIdEqualTo(
|
||||
Id value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'isarId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
isarIdGreaterThan(
|
||||
Id value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'isarId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> isarIdLessThan(
|
||||
Id value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'isarId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition> isarIdBetween(
|
||||
Id lower,
|
||||
Id upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'isarId',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
lastBackupEqualTo(DateTime value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'lastBackup',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
lastBackupGreaterThan(
|
||||
DateTime value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'lastBackup',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
lastBackupLessThan(
|
||||
DateTime value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'lastBackup',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
lastBackupBetween(
|
||||
DateTime lower,
|
||||
DateTime upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'lastBackup',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
selectionEqualTo(BackupSelection value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'selection',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
selectionGreaterThan(
|
||||
BackupSelection value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'selection',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
selectionLessThan(
|
||||
BackupSelection value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'selection',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
selectionBetween(
|
||||
BackupSelection lower,
|
||||
BackupSelection upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'selection',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension BackupAlbumQueryObject
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
|
||||
|
||||
extension BackupAlbumQueryLinks
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QFilterCondition> {}
|
||||
|
||||
extension BackupAlbumQuerySortBy
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QSortBy> {
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackup() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastBackup', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortByLastBackupDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastBackup', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortBySelection() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'selection', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> sortBySelectionDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'selection', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension BackupAlbumQuerySortThenBy
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QSortThenBy> {
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByIsarId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'isarId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByIsarIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'isarId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackup() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastBackup', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenByLastBackupDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastBackup', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenBySelection() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'selection', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> thenBySelectionDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'selection', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension BackupAlbumQueryWhereDistinct
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> {
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctById(
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'id', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctByLastBackup() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'lastBackup');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QDistinct> distinctBySelection() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'selection');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension BackupAlbumQueryProperty
|
||||
on QueryBuilder<BackupAlbum, BackupAlbum, QQueryProperty> {
|
||||
QueryBuilder<BackupAlbum, int, QQueryOperations> isarIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'isarId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, String, QQueryOperations> idProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'id');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, DateTime, QQueryOperations> lastBackupProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'lastBackup');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupSelection, QQueryOperations>
|
||||
selectionProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'selection');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class BackUpState {
|
||||
final double progressInPercentage;
|
||||
final CancellationToken cancelToken;
|
||||
final ServerInfoResponseDto serverInfo;
|
||||
final bool autoBackup;
|
||||
final bool backgroundBackup;
|
||||
final bool backupRequireWifi;
|
||||
final bool backupRequireCharging;
|
||||
@@ -40,6 +41,7 @@ class BackUpState {
|
||||
required this.progressInPercentage,
|
||||
required this.cancelToken,
|
||||
required this.serverInfo,
|
||||
required this.autoBackup,
|
||||
required this.backgroundBackup,
|
||||
required this.backupRequireWifi,
|
||||
required this.backupRequireCharging,
|
||||
@@ -58,6 +60,7 @@ class BackUpState {
|
||||
double? progressInPercentage,
|
||||
CancellationToken? cancelToken,
|
||||
ServerInfoResponseDto? serverInfo,
|
||||
bool? autoBackup,
|
||||
bool? backgroundBackup,
|
||||
bool? backupRequireWifi,
|
||||
bool? backupRequireCharging,
|
||||
@@ -75,6 +78,7 @@ class BackUpState {
|
||||
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
|
||||
cancelToken: cancelToken ?? this.cancelToken,
|
||||
serverInfo: serverInfo ?? this.serverInfo,
|
||||
autoBackup: autoBackup ?? this.autoBackup,
|
||||
backgroundBackup: backgroundBackup ?? this.backgroundBackup,
|
||||
backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi,
|
||||
backupRequireCharging:
|
||||
@@ -92,7 +96,7 @@ class BackUpState {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
|
||||
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -106,6 +110,7 @@ class BackUpState {
|
||||
other.progressInPercentage == progressInPercentage &&
|
||||
other.cancelToken == cancelToken &&
|
||||
other.serverInfo == serverInfo &&
|
||||
other.autoBackup == autoBackup &&
|
||||
other.backgroundBackup == backgroundBackup &&
|
||||
other.backupRequireWifi == backupRequireWifi &&
|
||||
other.backupRequireCharging == backupRequireCharging &&
|
||||
@@ -128,6 +133,7 @@ class BackUpState {
|
||||
progressInPercentage.hashCode ^
|
||||
cancelToken.hashCode ^
|
||||
serverInfo.hashCode ^
|
||||
autoBackup.hashCode ^
|
||||
backgroundBackup.hashCode ^
|
||||
backupRequireWifi.hashCode ^
|
||||
backupRequireCharging.hashCode ^
|
||||
|
||||
11
mobile/lib/modules/backup/models/duplicated_asset.model.dart
Normal file
11
mobile/lib/modules/backup/models/duplicated_asset.model.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
part 'duplicated_asset.model.g.dart';
|
||||
|
||||
@Collection(inheritance: false)
|
||||
class DuplicatedAsset {
|
||||
String id;
|
||||
DuplicatedAsset(this.id);
|
||||
Id get isarId => fastHash(id);
|
||||
}
|
||||
443
mobile/lib/modules/backup/models/duplicated_asset.model.g.dart
Normal file
443
mobile/lib/modules/backup/models/duplicated_asset.model.g.dart
Normal file
@@ -0,0 +1,443 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'duplicated_asset.model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// IsarCollectionGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters
|
||||
|
||||
extension GetDuplicatedAssetCollection on Isar {
|
||||
IsarCollection<DuplicatedAsset> get duplicatedAssets => this.collection();
|
||||
}
|
||||
|
||||
const DuplicatedAssetSchema = CollectionSchema(
|
||||
name: r'DuplicatedAsset',
|
||||
id: -2679334728174694496,
|
||||
properties: {
|
||||
r'id': PropertySchema(
|
||||
id: 0,
|
||||
name: r'id',
|
||||
type: IsarType.string,
|
||||
)
|
||||
},
|
||||
estimateSize: _duplicatedAssetEstimateSize,
|
||||
serialize: _duplicatedAssetSerialize,
|
||||
deserialize: _duplicatedAssetDeserialize,
|
||||
deserializeProp: _duplicatedAssetDeserializeProp,
|
||||
idName: r'isarId',
|
||||
indexes: {},
|
||||
links: {},
|
||||
embeddedSchemas: {},
|
||||
getId: _duplicatedAssetGetId,
|
||||
getLinks: _duplicatedAssetGetLinks,
|
||||
attach: _duplicatedAssetAttach,
|
||||
version: '3.0.5',
|
||||
);
|
||||
|
||||
int _duplicatedAssetEstimateSize(
|
||||
DuplicatedAsset object,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
var bytesCount = offsets.last;
|
||||
bytesCount += 3 + object.id.length * 3;
|
||||
return bytesCount;
|
||||
}
|
||||
|
||||
void _duplicatedAssetSerialize(
|
||||
DuplicatedAsset object,
|
||||
IsarWriter writer,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
writer.writeString(offsets[0], object.id);
|
||||
}
|
||||
|
||||
DuplicatedAsset _duplicatedAssetDeserialize(
|
||||
Id id,
|
||||
IsarReader reader,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
final object = DuplicatedAsset(
|
||||
reader.readString(offsets[0]),
|
||||
);
|
||||
return object;
|
||||
}
|
||||
|
||||
P _duplicatedAssetDeserializeProp<P>(
|
||||
IsarReader reader,
|
||||
int propertyId,
|
||||
int offset,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
switch (propertyId) {
|
||||
case 0:
|
||||
return (reader.readString(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
}
|
||||
}
|
||||
|
||||
Id _duplicatedAssetGetId(DuplicatedAsset object) {
|
||||
return object.isarId;
|
||||
}
|
||||
|
||||
List<IsarLinkBase<dynamic>> _duplicatedAssetGetLinks(DuplicatedAsset object) {
|
||||
return [];
|
||||
}
|
||||
|
||||
void _duplicatedAssetAttach(
|
||||
IsarCollection<dynamic> col, Id id, DuplicatedAsset object) {}
|
||||
|
||||
extension DuplicatedAssetQueryWhereSort
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QWhere> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhere> anyIsarId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(const IdWhereClause.any());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQueryWhere
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QWhereClause> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
|
||||
isarIdEqualTo(Id isarId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IdWhereClause.between(
|
||||
lower: isarId,
|
||||
upper: isarId,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
|
||||
isarIdNotEqualTo(Id isarId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
|
||||
);
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: isarId, includeLower: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: isarId, includeUpper: false),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
|
||||
isarIdGreaterThan(Id isarId, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: isarId, includeLower: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
|
||||
isarIdLessThan(Id isarId, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: isarId, includeUpper: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterWhereClause>
|
||||
isarIdBetween(
|
||||
Id lowerIsarId,
|
||||
Id upperIsarId, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IdWhereClause.between(
|
||||
lower: lowerIsarId,
|
||||
includeLower: includeLower,
|
||||
upper: upperIsarId,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQueryFilter
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idEqualTo(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idGreaterThan(
|
||||
String value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idLessThan(
|
||||
String value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idBetween(
|
||||
String lower,
|
||||
String upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'id',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idStartsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.startsWith(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idEndsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.endsWith(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idContains(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.contains(
|
||||
property: r'id',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idMatches(String pattern, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.matches(
|
||||
property: r'id',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'id',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
idIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
property: r'id',
|
||||
value: '',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
isarIdEqualTo(Id value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'isarId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
isarIdGreaterThan(
|
||||
Id value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'isarId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
isarIdLessThan(
|
||||
Id value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'isarId',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterFilterCondition>
|
||||
isarIdBetween(
|
||||
Id lower,
|
||||
Id upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'isarId',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQueryObject
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {}
|
||||
|
||||
extension DuplicatedAssetQueryLinks
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QFilterCondition> {}
|
||||
|
||||
extension DuplicatedAssetQuerySortBy
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QSortBy> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> sortById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> sortByIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQuerySortThenBy
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QSortThenBy> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenByIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy> thenByIsarId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'isarId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QAfterSortBy>
|
||||
thenByIsarIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'isarId', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQueryWhereDistinct
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QDistinct> {
|
||||
QueryBuilder<DuplicatedAsset, DuplicatedAsset, QDistinct> distinctById(
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'id', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension DuplicatedAssetQueryProperty
|
||||
on QueryBuilder<DuplicatedAsset, DuplicatedAsset, QQueryProperty> {
|
||||
QueryBuilder<DuplicatedAsset, int, QQueryOperations> isarIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'isarId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<DuplicatedAsset, String, QQueryOperations> idProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'id');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_duplicated_assets.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
@@ -29,6 +31,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
this._authState,
|
||||
this._backgroundService,
|
||||
this._galleryPermissionNotifier,
|
||||
this._db,
|
||||
this.ref,
|
||||
) : super(
|
||||
BackUpState(
|
||||
@@ -36,10 +39,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
allAssetsInDatabase: const [],
|
||||
progressInPercentage: 0,
|
||||
cancelToken: CancellationToken(),
|
||||
autoBackup: Store.get(StoreKey.autoBackup, false),
|
||||
backgroundBackup: false,
|
||||
backupRequireWifi: true,
|
||||
backupRequireCharging: false,
|
||||
backupTriggerDelay: 5000,
|
||||
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
|
||||
backupRequireCharging:
|
||||
Store.get(StoreKey.backupRequireCharging, false),
|
||||
backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay, 5000),
|
||||
serverInfo: ServerInfoResponseDto(
|
||||
diskAvailable: "0",
|
||||
diskAvailableRaw: 0,
|
||||
@@ -69,6 +74,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
final AuthenticationState _authState;
|
||||
final BackgroundService _backgroundService;
|
||||
final GalleryPermissionNotifier _galleryPermissionNotifier;
|
||||
final Isar _db;
|
||||
final Ref ref;
|
||||
|
||||
///
|
||||
@@ -116,6 +122,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
_updateBackupAssetCount();
|
||||
}
|
||||
|
||||
void setAutoBackup(bool enabled) {
|
||||
Store.put(StoreKey.autoBackup, enabled);
|
||||
state = state.copyWith(autoBackup: enabled);
|
||||
}
|
||||
|
||||
void configureBackgroundBackup({
|
||||
bool? enabled,
|
||||
bool? requireWifi,
|
||||
@@ -157,12 +168,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
triggerMaxDelay: state.backupTriggerDelay * 10,
|
||||
);
|
||||
if (success) {
|
||||
final box = Hive.box(backgroundBackupInfoBox);
|
||||
await Future.wait([
|
||||
box.put(backupRequireWifi, state.backupRequireWifi),
|
||||
box.put(backupRequireCharging, state.backupRequireCharging),
|
||||
box.put(backupTriggerDelay, state.backupTriggerDelay),
|
||||
]);
|
||||
await Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi);
|
||||
await Store.put(
|
||||
StoreKey.backupRequireCharging,
|
||||
state.backupRequireCharging,
|
||||
);
|
||||
await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
backgroundBackup: wasEnabled,
|
||||
@@ -201,16 +212,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
for (AssetPathEntity album in albums) {
|
||||
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||
|
||||
var assetCountInAlbum = await album.assetCountAsync;
|
||||
final assetCountInAlbum = await album.assetCountAsync;
|
||||
if (assetCountInAlbum > 0) {
|
||||
var assetList =
|
||||
final assetList =
|
||||
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
|
||||
|
||||
if (assetList.isNotEmpty) {
|
||||
var thumbnailAsset = assetList.first;
|
||||
final thumbnailAsset = assetList.first;
|
||||
|
||||
try {
|
||||
var thumbnailData = await thumbnailAsset
|
||||
final thumbnailData = await thumbnailAsset
|
||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||
availableAlbum =
|
||||
availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||
@@ -229,34 +240,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
state = state.copyWith(availableAlbums: availableAlbums);
|
||||
|
||||
// Put persistent storage info into local state of the app
|
||||
// Get local storage on selected backup album
|
||||
Box<HiveBackupAlbums> backupAlbumInfoBox =
|
||||
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get(
|
||||
backupInfoKey,
|
||||
defaultValue: HiveBackupAlbums(
|
||||
selectedAlbumIds: [],
|
||||
excludedAlbumsIds: [],
|
||||
lastSelectedBackupTime: [],
|
||||
lastExcludedBackupTime: [],
|
||||
),
|
||||
);
|
||||
|
||||
if (backupAlbumInfo == null) {
|
||||
log.severe(
|
||||
"backupAlbumInfo == null",
|
||||
"Failed to get Hive backup album information",
|
||||
);
|
||||
return;
|
||||
}
|
||||
final List<BackupAlbum> excludedBackupAlbums =
|
||||
await _backupService.excludedAlbumsQuery().findAll();
|
||||
final List<BackupAlbum> selectedBackupAlbums =
|
||||
await _backupService.selectedAlbumsQuery().findAll();
|
||||
|
||||
// First time backup - set isAll album is the default one for backup.
|
||||
if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
|
||||
if (selectedBackupAlbums.isEmpty) {
|
||||
log.info("First time backup; setup 'Recent(s)' album as default");
|
||||
|
||||
// Get album that contains all assets
|
||||
var list = await PhotoManager.getAssetPathList(
|
||||
final list = await PhotoManager.getAssetPathList(
|
||||
hasAll: true,
|
||||
onlyAll: true,
|
||||
type: RequestType.common,
|
||||
@@ -267,48 +261,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
AssetPathEntity albumHasAllAssets = list.first;
|
||||
|
||||
backupAlbumInfoBox.put(
|
||||
backupInfoKey,
|
||||
HiveBackupAlbums(
|
||||
selectedAlbumIds: [albumHasAllAssets.id],
|
||||
excludedAlbumsIds: [],
|
||||
lastSelectedBackupTime: [
|
||||
DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)
|
||||
],
|
||||
lastExcludedBackupTime: [],
|
||||
),
|
||||
final ba = BackupAlbum(
|
||||
albumHasAllAssets.id,
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
BackupSelection.select,
|
||||
);
|
||||
|
||||
backupAlbumInfo = backupAlbumInfoBox.get(backupInfoKey);
|
||||
await _db.writeTxn(() => _db.backupAlbums.put(ba));
|
||||
}
|
||||
|
||||
// Generate AssetPathEntity from id to add to local state
|
||||
try {
|
||||
Set<AvailableAlbum> selectedAlbums = {};
|
||||
for (var i = 0; i < backupAlbumInfo!.selectedAlbumIds.length; i++) {
|
||||
var albumAsset =
|
||||
await AssetPathEntity.fromId(backupAlbumInfo.selectedAlbumIds[i]);
|
||||
final Set<AvailableAlbum> selectedAlbums = {};
|
||||
for (final BackupAlbum ba in selectedBackupAlbums) {
|
||||
final albumAsset = await AssetPathEntity.fromId(ba.id);
|
||||
selectedAlbums.add(
|
||||
AvailableAlbum(
|
||||
albumEntity: albumAsset,
|
||||
lastBackup: backupAlbumInfo.lastSelectedBackupTime.length > i
|
||||
? backupAlbumInfo.lastSelectedBackupTime[i]
|
||||
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
|
||||
),
|
||||
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
|
||||
);
|
||||
}
|
||||
|
||||
Set<AvailableAlbum> excludedAlbums = {};
|
||||
for (var i = 0; i < backupAlbumInfo.excludedAlbumsIds.length; i++) {
|
||||
var albumAsset =
|
||||
await AssetPathEntity.fromId(backupAlbumInfo.excludedAlbumsIds[i]);
|
||||
final Set<AvailableAlbum> excludedAlbums = {};
|
||||
for (final BackupAlbum ba in excludedBackupAlbums) {
|
||||
final albumAsset = await AssetPathEntity.fromId(ba.id);
|
||||
excludedAlbums.add(
|
||||
AvailableAlbum(
|
||||
albumEntity: albumAsset,
|
||||
lastBackup: backupAlbumInfo.lastExcludedBackupTime.length > i
|
||||
? backupAlbumInfo.lastExcludedBackupTime[i]
|
||||
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
|
||||
),
|
||||
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
|
||||
);
|
||||
}
|
||||
state = state.copyWith(
|
||||
@@ -328,36 +303,36 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// Those assets are unique and are used as the total assets
|
||||
///
|
||||
Future<void> _updateBackupAssetCount() async {
|
||||
Set<String> duplicatedAssetIds = _backupService.getDuplicatedAssetIds();
|
||||
Set<AssetEntity> assetsFromSelectedAlbums = {};
|
||||
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
|
||||
final Set<AssetEntity> assetsFromSelectedAlbums = {};
|
||||
final Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||
|
||||
for (var album in state.selectedBackupAlbums) {
|
||||
var assets = await album.albumEntity.getAssetListRange(
|
||||
for (final album in state.selectedBackupAlbums) {
|
||||
final assets = await album.albumEntity.getAssetListRange(
|
||||
start: 0,
|
||||
end: await album.albumEntity.assetCountAsync,
|
||||
);
|
||||
assetsFromSelectedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
for (var album in state.excludedBackupAlbums) {
|
||||
var assets = await album.albumEntity.getAssetListRange(
|
||||
for (final album in state.excludedBackupAlbums) {
|
||||
final assets = await album.albumEntity.getAssetListRange(
|
||||
start: 0,
|
||||
end: await album.albumEntity.assetCountAsync,
|
||||
);
|
||||
assetsFromExcludedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
Set<AssetEntity> allUniqueAssets =
|
||||
final Set<AssetEntity> allUniqueAssets =
|
||||
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
|
||||
var allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
|
||||
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
|
||||
|
||||
if (allAssetsInDatabase == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find asset that were backup from selected albums
|
||||
Set<String> selectedAlbumsBackupAssets =
|
||||
final Set<String> selectedAlbumsBackupAssets =
|
||||
Set.from(allUniqueAssets.map((e) => e.id));
|
||||
|
||||
selectedAlbumsBackupAssets
|
||||
@@ -386,7 +361,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
// Save to persistent storage
|
||||
_updatePersistentAlbumsSelection();
|
||||
await _updatePersistentAlbumsSelection();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -395,7 +370,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
/// which albums are selected or excluded
|
||||
/// and then update the UI according to those information
|
||||
Future<void> getBackupInfo() async {
|
||||
var isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||
final isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||
|
||||
state = state.copyWith(backgroundBackup: isEnabled);
|
||||
|
||||
@@ -406,25 +381,38 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save user selection of selected albums and excluded albums to
|
||||
/// Hive database
|
||||
void _updatePersistentAlbumsSelection() {
|
||||
/// Save user selection of selected albums and excluded albums to database
|
||||
Future<void> _updatePersistentAlbumsSelection() {
|
||||
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
Box<HiveBackupAlbums> backupAlbumInfoBox =
|
||||
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
backupAlbumInfoBox.put(
|
||||
backupInfoKey,
|
||||
HiveBackupAlbums(
|
||||
selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
|
||||
excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(),
|
||||
lastSelectedBackupTime: state.selectedBackupAlbums
|
||||
.map((e) => e.lastBackup ?? epoch)
|
||||
.toList(),
|
||||
lastExcludedBackupTime: state.excludedBackupAlbums
|
||||
.map((e) => e.lastBackup ?? epoch)
|
||||
.toList(),
|
||||
),
|
||||
final selected = state.selectedBackupAlbums.map(
|
||||
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select),
|
||||
);
|
||||
final excluded = state.excludedBackupAlbums.map(
|
||||
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude),
|
||||
);
|
||||
final backupAlbums = selected.followedBy(excluded).toList();
|
||||
backupAlbums.sortBy((e) => e.id);
|
||||
return _db.writeTxn(() async {
|
||||
final dbAlbums = await _db.backupAlbums.where().sortById().findAll();
|
||||
final List<int> toDelete = [];
|
||||
final List<BackupAlbum> toUpsert = [];
|
||||
// stores the most recent `lastBackup` per album but always keeps the `selection` the user just made
|
||||
diffSortedListsSync(
|
||||
dbAlbums,
|
||||
backupAlbums,
|
||||
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
|
||||
both: (BackupAlbum a, BackupAlbum b) {
|
||||
b.lastBackup =
|
||||
a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
|
||||
toUpsert.add(b);
|
||||
return true;
|
||||
},
|
||||
onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId),
|
||||
onlySecond: (BackupAlbum b) => toUpsert.add(b),
|
||||
);
|
||||
await _db.backupAlbums.deleteAll(toDelete);
|
||||
await _db.backupAlbums.putAll(toUpsert);
|
||||
});
|
||||
}
|
||||
|
||||
/// Invoke backup process
|
||||
@@ -447,7 +435,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
|
||||
// Remove item that has already been backed up
|
||||
for (var assetId in state.allAssetsInDatabase) {
|
||||
for (final assetId in state.allAssetsInDatabase) {
|
||||
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
|
||||
}
|
||||
|
||||
@@ -547,7 +535,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
Future<void> _updateServerInfo() async {
|
||||
var serverInfo = await _serverInfoService.getServerInfo();
|
||||
final serverInfo = await _serverInfoService.getServerInfo();
|
||||
|
||||
// Update server info
|
||||
if (serverInfo != null) {
|
||||
@@ -559,7 +547,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
Future<void> _resumeBackup() async {
|
||||
// Check if user is login
|
||||
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
||||
final accessKey = Store.tryGet(StoreKey.accessToken);
|
||||
|
||||
// User has been logged out return
|
||||
if (accessKey == null || !_authState.isAuthenticated) {
|
||||
@@ -568,8 +556,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
// Check if this device is enable backup by the user
|
||||
if ((_authState.deviceInfo.deviceId == _authState.deviceId) &&
|
||||
_authState.deviceInfo.isAutoBackup) {
|
||||
if (state.autoBackup) {
|
||||
// check if backup is alreayd in process - then return
|
||||
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||
log.info("[_resumeBackup] Backup is already in progress - abort");
|
||||
@@ -585,70 +572,57 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
log.info("[_resumeBackup] Start back up");
|
||||
await startBackupProcess();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> resumeBackup() async {
|
||||
// assumes the background service is currently running
|
||||
// if true, waits until it has stopped to update the app state from HiveDB
|
||||
// before actually resuming backup by calling the internal `_resumeBackup`
|
||||
final BackUpProgressEnum previous = state.backupProgress;
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
|
||||
final bool hasLock = await _backgroundService.acquireLock();
|
||||
if (!hasLock) {
|
||||
log.warning("WARNING [resumeBackup] failed to acquireLock");
|
||||
return;
|
||||
}
|
||||
|
||||
await Future.wait([
|
||||
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
|
||||
Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
|
||||
Hive.openBox(backgroundBackupInfoBox),
|
||||
]);
|
||||
final HiveBackupAlbums? albums =
|
||||
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).get(backupInfoKey);
|
||||
final List<BackupAlbum> selectedBackupAlbums = await _db.backupAlbums
|
||||
.filter()
|
||||
.selectionEqualTo(BackupSelection.select)
|
||||
.findAll();
|
||||
final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
|
||||
.filter()
|
||||
.selectionEqualTo(BackupSelection.select)
|
||||
.findAll();
|
||||
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
|
||||
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
|
||||
if (albums != null) {
|
||||
if (selectedAlbums.isNotEmpty) {
|
||||
selectedAlbums = _updateAlbumsBackupTime(
|
||||
selectedAlbums,
|
||||
albums.selectedAlbumIds,
|
||||
albums.lastSelectedBackupTime,
|
||||
);
|
||||
}
|
||||
|
||||
if (excludedAlbums.isNotEmpty) {
|
||||
excludedAlbums = _updateAlbumsBackupTime(
|
||||
excludedAlbums,
|
||||
albums.excludedAlbumsIds,
|
||||
albums.lastExcludedBackupTime,
|
||||
);
|
||||
}
|
||||
if (selectedAlbums.isNotEmpty) {
|
||||
selectedAlbums = _updateAlbumsBackupTime(
|
||||
selectedAlbums,
|
||||
selectedBackupAlbums,
|
||||
);
|
||||
}
|
||||
final Box backgroundBox = Hive.box(backgroundBackupInfoBox);
|
||||
|
||||
if (excludedAlbums.isNotEmpty) {
|
||||
excludedAlbums = _updateAlbumsBackupTime(
|
||||
excludedAlbums,
|
||||
excludedBackupAlbums,
|
||||
);
|
||||
}
|
||||
final BackUpProgressEnum previous = state.backupProgress;
|
||||
state = state.copyWith(
|
||||
backupProgress: previous,
|
||||
backupProgress: BackUpProgressEnum.inBackground,
|
||||
selectedBackupAlbums: selectedAlbums,
|
||||
excludedBackupAlbums: excludedAlbums,
|
||||
backupRequireWifi: backgroundBox.get(backupRequireWifi),
|
||||
backupRequireCharging: backgroundBox.get(backupRequireCharging),
|
||||
backupTriggerDelay: backgroundBox.get(backupTriggerDelay),
|
||||
);
|
||||
// assumes the background service is currently running
|
||||
// if true, waits until it has stopped to start the backup
|
||||
final bool hasLock = await _backgroundService.acquireLock();
|
||||
if (hasLock) {
|
||||
state = state.copyWith(backupProgress: previous);
|
||||
}
|
||||
return _resumeBackup();
|
||||
}
|
||||
|
||||
Set<AvailableAlbum> _updateAlbumsBackupTime(
|
||||
Set<AvailableAlbum> albums,
|
||||
List<String> ids,
|
||||
List<DateTime> times,
|
||||
List<BackupAlbum> backupAlbums,
|
||||
) {
|
||||
Set<AvailableAlbum> result = {};
|
||||
for (int i = 0; i < ids.length; i++) {
|
||||
for (BackupAlbum ba in backupAlbums) {
|
||||
try {
|
||||
AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
|
||||
result.add(a.copyWith(lastBackup: times[i]));
|
||||
AvailableAlbum a = albums.firstWhere((e) => e.id == ba.id);
|
||||
result.add(a.copyWith(lastBackup: ba.lastBackup));
|
||||
} on StateError {
|
||||
log.severe(
|
||||
"[_updateAlbumBackupTime] failed to find album in state",
|
||||
@@ -667,35 +641,6 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
AppStateEnum.detached,
|
||||
];
|
||||
if (allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
|
||||
try {
|
||||
if (Hive.isBoxOpen(hiveBackupInfoBox)) {
|
||||
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
|
||||
}
|
||||
} catch (error) {
|
||||
log.info("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||
}
|
||||
try {
|
||||
if (Hive.isBoxOpen(duplicatedAssetsBox)) {
|
||||
await Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox).close();
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.severe(
|
||||
"[_notifyBackgroundServiceCanRun] failed to close box",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (Hive.isBoxOpen(backgroundBackupInfoBox)) {
|
||||
await Hive.box(backgroundBackupInfoBox).close();
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log.severe(
|
||||
"[_notifyBackgroundServiceCanRun] failed to close box",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
}
|
||||
_backgroundService.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -709,6 +654,7 @@ final backupProvider =
|
||||
ref.watch(authenticationProvider),
|
||||
ref.watch(backgroundServiceProvider),
|
||||
ref.watch(galleryPermissionNotifier.notifier),
|
||||
ref.watch(dbProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,37 +5,39 @@ import 'dart:io';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/files_helper.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:cancellation_token_http/http.dart' as http;
|
||||
|
||||
import '../models/hive_duplicated_assets.model.dart';
|
||||
|
||||
final backupServiceProvider = Provider(
|
||||
(ref) => BackupService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class BackupService {
|
||||
final httpClient = http.Client();
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
|
||||
BackupService(this._apiService);
|
||||
BackupService(this._apiService, this._db);
|
||||
|
||||
Future<List<String>?> getDeviceBackupAsset() async {
|
||||
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
try {
|
||||
return await _apiService.assetApi.getUserAssetsByDeviceId(deviceId);
|
||||
@@ -45,32 +47,28 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
void _saveDuplicatedAssetIdToLocalStorage(List<String> deviceAssetIds) {
|
||||
HiveDuplicatedAssets duplicatedAssets =
|
||||
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
|
||||
.get(duplicatedAssetsKey) ??
|
||||
HiveDuplicatedAssets(duplicatedAssetIds: []);
|
||||
|
||||
duplicatedAssets.duplicatedAssetIds =
|
||||
{...duplicatedAssets.duplicatedAssetIds, ...deviceAssetIds}.toList();
|
||||
|
||||
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
|
||||
.put(duplicatedAssetsKey, duplicatedAssets);
|
||||
Future<void> _saveDuplicatedAssetIds(List<String> deviceAssetIds) {
|
||||
final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList();
|
||||
return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates));
|
||||
}
|
||||
|
||||
/// Get duplicated asset id from Hive storage
|
||||
Set<String> getDuplicatedAssetIds() {
|
||||
HiveDuplicatedAssets duplicatedAssets =
|
||||
Hive.box<HiveDuplicatedAssets>(duplicatedAssetsBox)
|
||||
.get(duplicatedAssetsKey) ??
|
||||
HiveDuplicatedAssets(duplicatedAssetIds: []);
|
||||
|
||||
return duplicatedAssets.duplicatedAssetIds.toSet();
|
||||
/// Get duplicated asset id from database
|
||||
Future<Set<String>> getDuplicatedAssetIds() async {
|
||||
final duplicates = await _db.duplicatedAssets.where().findAll();
|
||||
return duplicates.map((e) => e.id).toSet();
|
||||
}
|
||||
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
selectedAlbumsQuery() =>
|
||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
|
||||
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
|
||||
excludedAlbumsQuery() =>
|
||||
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
|
||||
|
||||
/// Returns all assets newer than the last successful backup per album
|
||||
Future<List<AssetEntity>> buildUploadCandidates(
|
||||
HiveBackupAlbums backupAlbums,
|
||||
List<BackupAlbum> selectedBackupAlbums,
|
||||
List<BackupAlbum> excludedBackupAlbums,
|
||||
) async {
|
||||
final filter = FilterOptionGroup(
|
||||
containsPathModified: true,
|
||||
@@ -81,66 +79,55 @@ class BackupService {
|
||||
);
|
||||
final now = DateTime.now();
|
||||
final List<AssetPathEntity?> selectedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(
|
||||
backupAlbums.selectedAlbumIds,
|
||||
backupAlbums.lastSelectedBackupTime,
|
||||
filter,
|
||||
now,
|
||||
);
|
||||
await _loadAlbumsWithTimeFilter(selectedBackupAlbums, filter, now);
|
||||
if (selectedAlbums.every((e) => e == null)) {
|
||||
return [];
|
||||
}
|
||||
final int allIdx = selectedAlbums.indexWhere((e) => e != null && e.isAll);
|
||||
if (allIdx != -1) {
|
||||
final List<AssetPathEntity?> excludedAlbums =
|
||||
await _loadAlbumsWithTimeFilter(
|
||||
backupAlbums.excludedAlbumsIds,
|
||||
backupAlbums.lastExcludedBackupTime,
|
||||
filter,
|
||||
now,
|
||||
);
|
||||
await _loadAlbumsWithTimeFilter(excludedBackupAlbums, filter, now);
|
||||
final List<AssetEntity> toAdd = await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums.slice(allIdx, allIdx + 1),
|
||||
backupAlbums.lastSelectedBackupTime.slice(allIdx, allIdx + 1),
|
||||
selectedBackupAlbums.slice(allIdx, allIdx + 1),
|
||||
now,
|
||||
);
|
||||
final List<AssetEntity> toRemove = await _fetchAssetsAndUpdateLastBackup(
|
||||
excludedAlbums,
|
||||
backupAlbums.lastExcludedBackupTime,
|
||||
excludedBackupAlbums,
|
||||
now,
|
||||
);
|
||||
return toAdd.toSet().difference(toRemove.toSet()).toList();
|
||||
} else {
|
||||
return await _fetchAssetsAndUpdateLastBackup(
|
||||
selectedAlbums,
|
||||
backupAlbums.lastSelectedBackupTime,
|
||||
selectedBackupAlbums,
|
||||
now,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
|
||||
List<String> albumIds,
|
||||
List<DateTime> lastBackups,
|
||||
List<BackupAlbum> albums,
|
||||
FilterOptionGroup filter,
|
||||
DateTime now,
|
||||
) async {
|
||||
List<AssetPathEntity?> result = List.filled(albumIds.length, null);
|
||||
for (int i = 0; i < albumIds.length; i++) {
|
||||
List<AssetPathEntity?> result = [];
|
||||
for (BackupAlbum a in albums) {
|
||||
try {
|
||||
final AssetPathEntity album =
|
||||
await AssetPathEntity.obtainPathFromProperties(
|
||||
id: albumIds[i],
|
||||
id: a.id,
|
||||
optionGroup: filter.copyWith(
|
||||
updateTimeCond: DateTimeCond(
|
||||
// subtract 2 seconds to prevent missing assets due to rounding issues
|
||||
min: lastBackups[i].subtract(const Duration(seconds: 2)),
|
||||
min: a.lastBackup.subtract(const Duration(seconds: 2)),
|
||||
max: now,
|
||||
),
|
||||
),
|
||||
maxDateTimeToNow: false,
|
||||
);
|
||||
result[i] = album;
|
||||
result.add(album);
|
||||
} on StateError {
|
||||
// either there are no assets matching the filter criteria OR the album no longer exists
|
||||
}
|
||||
@@ -150,17 +137,18 @@ class BackupService {
|
||||
|
||||
Future<List<AssetEntity>> _fetchAssetsAndUpdateLastBackup(
|
||||
List<AssetPathEntity?> albums,
|
||||
List<DateTime> lastBackup,
|
||||
List<BackupAlbum> backupAlbums,
|
||||
DateTime now,
|
||||
) async {
|
||||
List<AssetEntity> result = [];
|
||||
for (int i = 0; i < albums.length; i++) {
|
||||
final AssetPathEntity? a = albums[i];
|
||||
if (a != null && a.lastModified?.isBefore(lastBackup[i]) != true) {
|
||||
if (a != null &&
|
||||
a.lastModified?.isBefore(backupAlbums[i].lastBackup) != true) {
|
||||
result.addAll(
|
||||
await a.getAssetListRange(start: 0, end: await a.assetCountAsync),
|
||||
);
|
||||
lastBackup[i] = now;
|
||||
backupAlbums[i].lastBackup = now;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -173,7 +161,7 @@ class BackupService {
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
final Set<String> duplicatedAssetIds = getDuplicatedAssetIds();
|
||||
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
|
||||
candidates = duplicatedAssetIds.isEmpty
|
||||
? candidates
|
||||
: candidates
|
||||
@@ -184,7 +172,7 @@ class BackupService {
|
||||
}
|
||||
final Set<String> existing = {};
|
||||
try {
|
||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final CheckExistingAssetsResponseDto? duplicates =
|
||||
await _apiService.assetApi.checkExistingAssets(
|
||||
CheckExistingAssetsDto(
|
||||
@@ -215,8 +203,8 @@ class BackupService {
|
||||
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
|
||||
Function(ErrorUploadAsset) errorCb,
|
||||
) async {
|
||||
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
File? file;
|
||||
bool anyErrors = false;
|
||||
final List<String> duplicatedAssetIds = [];
|
||||
@@ -247,21 +235,22 @@ class BackupService {
|
||||
),
|
||||
);
|
||||
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
var req = MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$savedEndpoint/asset/upload'),
|
||||
onProgress: ((bytes, totalBytes) =>
|
||||
uploadProgressCb(bytes, totalBytes)),
|
||||
);
|
||||
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
|
||||
req.headers["Authorization"] =
|
||||
"Bearer ${Store.get(StoreKey.accessToken)}";
|
||||
req.headers["Transfer-Encoding"] = "chunked";
|
||||
|
||||
req.fields['deviceAssetId'] = entity.id;
|
||||
req.fields['deviceId'] = deviceId;
|
||||
req.fields['assetType'] = _getAssetType(entity.type);
|
||||
req.fields['fileCreatedAt'] = entity.createDateTime.toIso8601String();
|
||||
req.fields['fileModifiedAt'] = entity.modifiedDateTime.toIso8601String();
|
||||
req.fields['fileModifiedAt'] =
|
||||
entity.modifiedDateTime.toIso8601String();
|
||||
req.fields['isFavorite'] = entity.isFavorite.toString();
|
||||
req.fields['fileExtension'] = fileExtension;
|
||||
req.fields['duration'] = entity.videoDuration.toString();
|
||||
@@ -332,7 +321,7 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
if (duplicatedAssetIds.isNotEmpty) {
|
||||
_saveDuplicatedAssetIdToLocalStorage(duplicatedAssetIds);
|
||||
await _saveDuplicatedAssetIds(duplicatedAssetIds);
|
||||
}
|
||||
return !anyErrors;
|
||||
}
|
||||
@@ -375,31 +364,6 @@ class BackupService {
|
||||
return "OTHER";
|
||||
}
|
||||
}
|
||||
|
||||
Future<DeviceInfoResponseDto> setAutoBackup(
|
||||
bool status,
|
||||
String deviceId,
|
||||
DeviceTypeEnum deviceType,
|
||||
) async {
|
||||
try {
|
||||
var updatedDeviceInfo = await _apiService.deviceInfoApi.upsertDeviceInfo(
|
||||
UpsertDeviceInfoDto(
|
||||
deviceId: deviceId,
|
||||
deviceType: deviceType,
|
||||
isAutoBackup: status,
|
||||
),
|
||||
);
|
||||
|
||||
if (updatedDeviceInfo == null) {
|
||||
throw Exception("Error updating device info");
|
||||
}
|
||||
|
||||
return updatedDeviceInfo;
|
||||
} catch (e) {
|
||||
debugPrint("Error setAutoBackup: ${e.toString()}");
|
||||
throw Error();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MultipartRequest extends http.MultipartRequest {
|
||||
|
||||
@@ -10,9 +10,7 @@ import 'package:immich_mobile/modules/backup/providers/error_backup_list.provide
|
||||
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/current_backup_asset_info_box.dart';
|
||||
import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.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/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
@@ -26,11 +24,10 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
BackUpState backupState = ref.watch(backupProvider);
|
||||
AuthenticationState authenticationState = ref.watch(authenticationProvider);
|
||||
final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
|
||||
|
||||
final appRefreshDisabled = Platform.isIOS &&
|
||||
settings?.appRefreshEnabled != true;
|
||||
final appRefreshDisabled =
|
||||
Platform.isIOS && settings?.appRefreshEnabled != true;
|
||||
bool hasExclusiveAccess =
|
||||
backupState.backupProgress != BackUpProgressEnum.inBackground;
|
||||
bool shouldBackup = backupState.allUniqueAssets.length -
|
||||
@@ -102,11 +99,11 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
ListTile buildAutoBackupController() {
|
||||
var backUpOption = authenticationState.deviceInfo.isAutoBackup
|
||||
final isAutoBackup = backupState.autoBackup;
|
||||
final backUpOption = isAutoBackup
|
||||
? "backup_controller_page_status_on".tr()
|
||||
: "backup_controller_page_status_off".tr();
|
||||
var isAutoBackup = authenticationState.deviceInfo.isAutoBackup;
|
||||
var backupBtnText = authenticationState.deviceInfo.isAutoBackup
|
||||
final backupBtnText = isAutoBackup
|
||||
? "backup_controller_page_turn_off".tr()
|
||||
: "backup_controller_page_turn_on".tr();
|
||||
return ListTile(
|
||||
@@ -134,17 +131,9 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
if (isAutoBackup) {
|
||||
ref
|
||||
.read(authenticationProvider.notifier)
|
||||
.setAutoBackup(false);
|
||||
} else {
|
||||
ref
|
||||
.read(authenticationProvider.notifier)
|
||||
.setAutoBackup(true);
|
||||
}
|
||||
},
|
||||
onPressed: () => ref
|
||||
.read(backupProvider.notifier)
|
||||
.setAutoBackup(!isAutoBackup),
|
||||
child: Text(
|
||||
backupBtnText,
|
||||
style: const TextStyle(
|
||||
@@ -292,15 +281,13 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
dense: true,
|
||||
activeColor: activeColor,
|
||||
value: isWifiRequired,
|
||||
onChanged: hasExclusiveAccess
|
||||
? (isChecked) => ref
|
||||
.read(backupProvider.notifier)
|
||||
.configureBackgroundBackup(
|
||||
requireWifi: isChecked,
|
||||
onError: showErrorToUser,
|
||||
onBatteryInfo: showBatteryOptimizationInfoToUser,
|
||||
)
|
||||
: null,
|
||||
onChanged: (isChecked) => ref
|
||||
.read(backupProvider.notifier)
|
||||
.configureBackgroundBackup(
|
||||
requireWifi: isChecked,
|
||||
onError: showErrorToUser,
|
||||
onBatteryInfo: showBatteryOptimizationInfoToUser,
|
||||
),
|
||||
),
|
||||
if (isBackgroundEnabled)
|
||||
SwitchListTile.adaptive(
|
||||
@@ -314,21 +301,18 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
dense: true,
|
||||
activeColor: activeColor,
|
||||
value: isChargingRequired,
|
||||
onChanged: hasExclusiveAccess
|
||||
? (isChecked) => ref
|
||||
.read(backupProvider.notifier)
|
||||
.configureBackgroundBackup(
|
||||
requireCharging: isChecked,
|
||||
onError: showErrorToUser,
|
||||
onBatteryInfo: showBatteryOptimizationInfoToUser,
|
||||
)
|
||||
: null,
|
||||
onChanged: (isChecked) => ref
|
||||
.read(backupProvider.notifier)
|
||||
.configureBackgroundBackup(
|
||||
requireCharging: isChecked,
|
||||
onError: showErrorToUser,
|
||||
onBatteryInfo: showBatteryOptimizationInfoToUser,
|
||||
),
|
||||
),
|
||||
if (isBackgroundEnabled && Platform.isAndroid)
|
||||
ListTile(
|
||||
isThreeLine: false,
|
||||
dense: true,
|
||||
enabled: hasExclusiveAccess,
|
||||
title: const Text(
|
||||
'backup_controller_page_background_delay',
|
||||
style: TextStyle(
|
||||
@@ -339,9 +323,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
),
|
||||
subtitle: Slider(
|
||||
value: triggerDelay.value,
|
||||
onChanged: hasExclusiveAccess
|
||||
? (double v) => triggerDelay.value = v
|
||||
: null,
|
||||
onChanged: (double v) => triggerDelay.value = v,
|
||||
onChangeEnd: (double v) => ref
|
||||
.read(backupProvider.notifier)
|
||||
.configureBackgroundBackup(
|
||||
@@ -379,23 +361,21 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
if (isBackgroundEnabled && Platform.isIOS)
|
||||
FutureBuilder(
|
||||
future: ref
|
||||
.read(backgroundServiceProvider)
|
||||
.getIOSBackgroundAppRefreshEnabled(),
|
||||
.read(backgroundServiceProvider)
|
||||
.getIOSBackgroundAppRefreshEnabled(),
|
||||
builder: (context, snapshot) {
|
||||
final enabled = snapshot.data as bool?;
|
||||
// If it's not enabled, show them some kind of alert that says
|
||||
// background refresh is not enabled
|
||||
if (enabled != null && !enabled) {
|
||||
|
||||
}
|
||||
if (enabled != null && !enabled) {}
|
||||
// If it's enabled, no need to bother them
|
||||
return Container();
|
||||
},
|
||||
),
|
||||
if (isBackgroundEnabled && settings != null)
|
||||
if (Platform.isIOS && isBackgroundEnabled && settings != null)
|
||||
IosDebugInfoTile(
|
||||
settings: settings,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -403,7 +383,9 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
Widget buildBackgroundAppRefreshWarning() {
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
leading: const Icon(Icons.task_outlined,),
|
||||
leading: const Icon(
|
||||
Icons.task_outlined,
|
||||
),
|
||||
title: const Text(
|
||||
'backup_controller_page_background_app_refresh_disabled_title',
|
||||
style: TextStyle(
|
||||
@@ -420,7 +402,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
'backup_controller_page_background_app_refresh_disabled_content',
|
||||
).tr(),
|
||||
),
|
||||
ElevatedButton(
|
||||
ElevatedButton(
|
||||
onPressed: () => openAppSettings(),
|
||||
child: const Text(
|
||||
'backup_controller_page_background_app_refresh_enable_button_text',
|
||||
@@ -533,12 +515,9 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
trailing: ElevatedButton(
|
||||
onPressed: hasExclusiveAccess
|
||||
? () {
|
||||
AutoRouter.of(context)
|
||||
.push(const BackupAlbumSelectionRoute());
|
||||
}
|
||||
: null,
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
|
||||
},
|
||||
child: const Text(
|
||||
"backup_controller_page_select",
|
||||
style: TextStyle(
|
||||
@@ -598,28 +577,12 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
buildBackgroundBackupInfo() {
|
||||
return hasExclusiveAccess
|
||||
? const SizedBox.shrink()
|
||||
: Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20), // if you need this
|
||||
side: BorderSide(
|
||||
color: isDarkMode
|
||||
? const Color.fromARGB(255, 56, 56, 56)
|
||||
: Colors.black12,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
elevation: 0,
|
||||
borderOnForeground: false,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
"Background backup is currently running, some actions are disabled",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
return const ListTile(
|
||||
leading: Icon(Icons.info_outline_rounded),
|
||||
title: Text(
|
||||
"Background backup is currently running, cannot start manual backup",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
@@ -652,7 +615,6 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
).tr(),
|
||||
),
|
||||
buildBackgroundBackupInfo(),
|
||||
buildFolderSelectionTile(),
|
||||
BackupInfoCard(
|
||||
title: "backup_controller_page_total".tr(),
|
||||
@@ -681,22 +643,20 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: Platform.isIOS
|
||||
? (
|
||||
appRefreshDisabled
|
||||
? buildBackgroundAppRefreshWarning()
|
||||
: buildBackgroundBackupController()
|
||||
) : buildBackgroundBackupController(),
|
||||
? (appRefreshDisabled
|
||||
? buildBackgroundAppRefreshWarning()
|
||||
: buildBackgroundBackupController())
|
||||
: buildBackgroundBackupController(),
|
||||
),
|
||||
const Divider(),
|
||||
buildStorageInformation(),
|
||||
const Divider(),
|
||||
const CurrentUploadingAssetInfoBox(),
|
||||
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
|
||||
buildBackupButton()
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
|
||||
class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
|
||||
class FavoriteSelectionNotifier extends StateNotifier<Set<int>> {
|
||||
FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) {
|
||||
state = assetsState.allAssets
|
||||
.where((asset) => asset.isFavorite)
|
||||
@@ -13,7 +13,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
|
||||
final AssetsState assetsState;
|
||||
final AssetNotifier assetNotifier;
|
||||
|
||||
void _setFavoriteForAssetId(String id, bool favorite) {
|
||||
void _setFavoriteForAssetId(int id, bool favorite) {
|
||||
if (!favorite) {
|
||||
state = state.difference({id});
|
||||
} else {
|
||||
@@ -21,7 +21,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isFavorite(String id) {
|
||||
bool _isFavorite(int id) {
|
||||
return state.contains(id);
|
||||
}
|
||||
|
||||
@@ -38,22 +38,22 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
|
||||
|
||||
Future<void> addToFavorites(Iterable<Asset> assets) {
|
||||
state = state.union(assets.map((a) => a.id).toSet());
|
||||
final futures = assets.map((a) =>
|
||||
assetNotifier.toggleFavorite(
|
||||
a,
|
||||
true,
|
||||
),
|
||||
);
|
||||
final futures = assets.map(
|
||||
(a) => assetNotifier.toggleFavorite(
|
||||
a,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
return Future.wait(futures);
|
||||
}
|
||||
}
|
||||
|
||||
final favoriteProvider =
|
||||
StateNotifierProvider<FavoriteSelectionNotifier, Set<String>>((ref) {
|
||||
StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) {
|
||||
return FavoriteSelectionNotifier(
|
||||
ref.watch(assetProvider),
|
||||
ref.watch(assetProvider.notifier),
|
||||
ref.watch(assetProvider),
|
||||
ref.watch(assetProvider.notifier),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/modules/favorite/ui/favorite_image.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
|
||||
class FavoritesPage extends HookConsumerWidget {
|
||||
const FavoritesPage({Key? key}) : super(key: key);
|
||||
@@ -22,46 +20,14 @@ class FavoritesPage extends HookConsumerWidget {
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text(
|
||||
'favorites_page_title',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildImageGrid() {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
if (ref.watch(favoriteAssetProvider).isNotEmpty) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(top: 10.0),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount:
|
||||
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
crossAxisSpacing: 5.0,
|
||||
mainAxisSpacing: 5,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(
|
||||
BuildContext context,
|
||||
int index,
|
||||
) {
|
||||
return FavoriteImage(
|
||||
ref.watch(favoriteAssetProvider)[index],
|
||||
ref.watch(favoriteAssetProvider),
|
||||
);
|
||||
},
|
||||
childCount: ref.watch(favoriteAssetProvider).length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SliverToBoxAdapter();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: buildAppBar(),
|
||||
body: CustomScrollView(
|
||||
slivers: [buildImageGrid()],
|
||||
body: ImmichAssetGrid(
|
||||
assets: ref.watch(favoriteAssetProvider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,300 +1,106 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'asset_grid_data_structure.dart';
|
||||
import 'group_divider_title.dart';
|
||||
import 'disable_multi_select_button.dart';
|
||||
import 'draggable_scrollbar_custom.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
typedef ImmichAssetGridSelectionListener = void Function(
|
||||
bool,
|
||||
Set<Asset>,
|
||||
);
|
||||
|
||||
class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener _itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
bool _scrolling = false;
|
||||
final Set<String> _selectedAssets = HashSet();
|
||||
|
||||
Set<Asset> _getSelectedAssets() {
|
||||
return _selectedAssets
|
||||
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
|
||||
.whereNotNull()
|
||||
.toSet();
|
||||
}
|
||||
|
||||
void _callSelectionListener(bool selectionActive) {
|
||||
widget.listener?.call(selectionActive, _getSelectedAssets());
|
||||
}
|
||||
|
||||
void _selectAssets(List<Asset> assets) {
|
||||
setState(() {
|
||||
for (var e in assets) {
|
||||
_selectedAssets.add(e.id);
|
||||
}
|
||||
_callSelectionListener(true);
|
||||
});
|
||||
}
|
||||
|
||||
void _deselectAssets(List<Asset> assets) {
|
||||
setState(() {
|
||||
for (var e in assets) {
|
||||
_selectedAssets.remove(e.id);
|
||||
}
|
||||
_callSelectionListener(_selectedAssets.isNotEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
void _deselectAll() {
|
||||
setState(() {
|
||||
_selectedAssets.clear();
|
||||
});
|
||||
|
||||
_callSelectionListener(false);
|
||||
}
|
||||
|
||||
bool _allAssetsSelected(List<Asset> assets) {
|
||||
return widget.selectionActive &&
|
||||
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
|
||||
}
|
||||
|
||||
Widget _buildThumbnailOrPlaceholder(
|
||||
Asset asset,
|
||||
bool placeholder,
|
||||
) {
|
||||
if (placeholder) {
|
||||
return const DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
return ThumbnailImage(
|
||||
asset: asset,
|
||||
assetList: widget.allAssets,
|
||||
multiselectEnabled: widget.selectionActive,
|
||||
isSelected: widget.selectionActive && _selectedAssets.contains(asset.id),
|
||||
onSelect: () => _selectAssets([asset]),
|
||||
onDeselect: () => _deselectAssets([asset]),
|
||||
useGrayBoxPlaceholder: true,
|
||||
showStorageIndicator: widget.showStorageIndicator,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(
|
||||
BuildContext context,
|
||||
RenderAssetGridRow row,
|
||||
bool scrolling,
|
||||
) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.maxWidth / widget.assetsPerRow -
|
||||
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
|
||||
return Row(
|
||||
key: Key("asset-row-${row.assets.first.id}"),
|
||||
children: row.assets.mapIndexed((int index, Asset asset) {
|
||||
bool last = asset.id == row.assets.last.id;
|
||||
|
||||
return Container(
|
||||
key: Key("asset-${asset.id}"),
|
||||
width: size * row.widthDistribution[index],
|
||||
height: size,
|
||||
margin: EdgeInsets.only(
|
||||
top: widget.margin,
|
||||
right: last ? 0.0 : widget.margin,
|
||||
),
|
||||
child: _buildThumbnailOrPlaceholder(asset, scrolling),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(
|
||||
BuildContext context,
|
||||
String title,
|
||||
List<Asset> assets,
|
||||
) {
|
||||
return GroupDividerTitle(
|
||||
text: title,
|
||||
multiselectEnabled: widget.selectionActive,
|
||||
onSelect: () => _selectAssets(assets),
|
||||
onDeselect: () => _deselectAssets(assets),
|
||||
selected: _allAssetsSelected(assets),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthTitle(BuildContext context, String title) {
|
||||
return Padding(
|
||||
key: Key("month-$title"),
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 32),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).textTheme.displayLarge?.color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _itemBuilder(BuildContext c, int position) {
|
||||
final item = widget.renderList.elements[position];
|
||||
|
||||
if (item.type == RenderAssetGridElementType.groupDividerTitle) {
|
||||
return _buildTitle(c, item.title!, item.relatedAssetList!);
|
||||
} else if (item.type == RenderAssetGridElementType.monthTitle) {
|
||||
return _buildMonthTitle(c, item.title!);
|
||||
} else if (item.type == RenderAssetGridElementType.assetRow) {
|
||||
return _buildAssetRow(c, item.assetRow!, _scrolling);
|
||||
}
|
||||
|
||||
return const Text("Invalid widget type!");
|
||||
}
|
||||
|
||||
Text _labelBuilder(int pos) {
|
||||
final date = widget.renderList.elements[pos].date;
|
||||
return Text(
|
||||
DateFormat.yMMMM().format(date),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMultiSelectIndicator() {
|
||||
return DisableMultiSelectButton(
|
||||
onPressed: () => _deselectAll(),
|
||||
selectedItemCount: _selectedAssets.length,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetGrid() {
|
||||
final useDragScrolling = widget.allAssets.length >= 20;
|
||||
|
||||
void dragScrolling(bool active) {
|
||||
setState(() {
|
||||
_scrolling = active;
|
||||
});
|
||||
}
|
||||
|
||||
final listWidget = ScrollablePositionedList.builder(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 220,
|
||||
),
|
||||
itemBuilder: _itemBuilder,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
itemScrollController: _itemScrollController,
|
||||
itemCount: widget.renderList.elements.length,
|
||||
addRepaintBoundaries: true,
|
||||
);
|
||||
|
||||
if (!useDragScrolling) {
|
||||
return listWidget;
|
||||
}
|
||||
|
||||
return DraggableScrollbar.semicircle(
|
||||
scrollStateListener: dragScrolling,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
controller: _itemScrollController,
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
labelTextBuilder: _labelBuilder,
|
||||
labelConstraints: const BoxConstraints(maxHeight: 28),
|
||||
scrollbarAnimationDuration: const Duration(seconds: 1),
|
||||
scrollbarTimeToFade: const Duration(seconds: 4),
|
||||
child: listWidget,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ImmichAssetGrid oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!widget.selectionActive) {
|
||||
setState(() {
|
||||
_selectedAssets.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> onWillPop() async {
|
||||
if (widget.selectionActive && _selectedAssets.isNotEmpty) {
|
||||
_deselectAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
scrollToTopNotifierProvider.addListener(_scrollToTop);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollToTopNotifierProvider.removeListener(_scrollToTop);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToTop() {
|
||||
// for some reason, this is necessary as well in order
|
||||
// to correctly reposition the drag thumb scroll bar
|
||||
_itemScrollController.jumpTo(
|
||||
index: 0,
|
||||
);
|
||||
_itemScrollController.scrollTo(
|
||||
index: 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildAssetGrid(),
|
||||
if (widget.selectionActive) _buildMultiSelectIndicator(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImmichAssetGrid extends StatefulWidget {
|
||||
final RenderList renderList;
|
||||
final int assetsPerRow;
|
||||
class ImmichAssetGrid extends HookConsumerWidget {
|
||||
final int? assetsPerRow;
|
||||
final double margin;
|
||||
final bool showStorageIndicator;
|
||||
final bool? showStorageIndicator;
|
||||
final ImmichAssetGridSelectionListener? listener;
|
||||
final bool selectionActive;
|
||||
final List<Asset> allAssets;
|
||||
final List<Asset> assets;
|
||||
final RenderList? renderList;
|
||||
final Future<void> Function()? onRefresh;
|
||||
|
||||
const ImmichAssetGrid({
|
||||
super.key,
|
||||
required this.renderList,
|
||||
required this.allAssets,
|
||||
required this.assetsPerRow,
|
||||
required this.showStorageIndicator,
|
||||
required this.assets,
|
||||
this.onRefresh,
|
||||
this.renderList,
|
||||
this.assetsPerRow,
|
||||
this.showStorageIndicator,
|
||||
this.listener,
|
||||
this.margin = 5.0,
|
||||
this.selectionActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return ImmichAssetGridState();
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
final renderListFuture = ref.watch(renderListProvider(assets));
|
||||
|
||||
// Needs to suppress hero animations when navigating to this widget
|
||||
final enableHeroAnimations = useState(false);
|
||||
|
||||
// Wait for transition to complete, then re-enable
|
||||
ModalRoute.of(context)?.animation?.addListener(() {
|
||||
// If we've already enabled, we are done
|
||||
if (enableHeroAnimations.value) {
|
||||
return;
|
||||
}
|
||||
final animation = ModalRoute.of(context)?.animation;
|
||||
if (animation != null) {
|
||||
// When the animation is complete, re-enable hero animations
|
||||
enableHeroAnimations.value = animation.isCompleted;
|
||||
}
|
||||
});
|
||||
|
||||
Future<bool> onWillPop() async {
|
||||
enableHeroAnimations.value = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (renderList != null) {
|
||||
return WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: HeroMode(
|
||||
enabled: enableHeroAnimations.value,
|
||||
child: ImmichAssetGridView(
|
||||
allAssets: assets,
|
||||
onRefresh: onRefresh,
|
||||
assetsPerRow: assetsPerRow ??
|
||||
settings.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
listener: listener,
|
||||
showStorageIndicator: showStorageIndicator ??
|
||||
settings.getSetting(AppSettingsEnum.storageIndicator),
|
||||
renderList: renderList!,
|
||||
margin: margin,
|
||||
selectionActive: selectionActive,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return renderListFuture.when(
|
||||
data: (renderList) => WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: HeroMode(
|
||||
enabled: enableHeroAnimations.value,
|
||||
child: ImmichAssetGridView(
|
||||
allAssets: assets,
|
||||
onRefresh: onRefresh,
|
||||
assetsPerRow: assetsPerRow ??
|
||||
settings.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
listener: listener,
|
||||
showStorageIndicator: showStorageIndicator ??
|
||||
settings.getSetting(AppSettingsEnum.storageIndicator),
|
||||
renderList: renderList,
|
||||
margin: margin,
|
||||
selectionActive: selectionActive,
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (err, stack) => Center(child: Text("$err")),
|
||||
loading: () => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'asset_grid_data_structure.dart';
|
||||
import 'group_divider_title.dart';
|
||||
import 'disable_multi_select_button.dart';
|
||||
import 'draggable_scrollbar_custom.dart';
|
||||
|
||||
typedef ImmichAssetGridSelectionListener = void Function(
|
||||
bool,
|
||||
Set<Asset>,
|
||||
);
|
||||
|
||||
class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener _itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
bool _scrolling = false;
|
||||
final Set<int> _selectedAssets = HashSet();
|
||||
|
||||
Set<Asset> _getSelectedAssets() {
|
||||
return _selectedAssets
|
||||
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
|
||||
.whereNotNull()
|
||||
.toSet();
|
||||
}
|
||||
|
||||
void _callSelectionListener(bool selectionActive) {
|
||||
widget.listener?.call(selectionActive, _getSelectedAssets());
|
||||
}
|
||||
|
||||
void _selectAssets(List<Asset> assets) {
|
||||
setState(() {
|
||||
for (var e in assets) {
|
||||
_selectedAssets.add(e.id);
|
||||
}
|
||||
_callSelectionListener(true);
|
||||
});
|
||||
}
|
||||
|
||||
void _deselectAssets(List<Asset> assets) {
|
||||
setState(() {
|
||||
for (var e in assets) {
|
||||
_selectedAssets.remove(e.id);
|
||||
}
|
||||
_callSelectionListener(_selectedAssets.isNotEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
void _deselectAll() {
|
||||
setState(() {
|
||||
_selectedAssets.clear();
|
||||
});
|
||||
|
||||
_callSelectionListener(false);
|
||||
}
|
||||
|
||||
bool _allAssetsSelected(List<Asset> assets) {
|
||||
return widget.selectionActive &&
|
||||
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
|
||||
}
|
||||
|
||||
Widget _buildThumbnailOrPlaceholder(
|
||||
Asset asset,
|
||||
bool placeholder,
|
||||
) {
|
||||
if (placeholder) {
|
||||
return const DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
return ThumbnailImage(
|
||||
asset: asset,
|
||||
assetList: widget.allAssets,
|
||||
multiselectEnabled: widget.selectionActive,
|
||||
isSelected: widget.selectionActive && _selectedAssets.contains(asset.id),
|
||||
onSelect: () => _selectAssets([asset]),
|
||||
onDeselect: () => _deselectAssets([asset]),
|
||||
useGrayBoxPlaceholder: true,
|
||||
showStorageIndicator: widget.showStorageIndicator,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(
|
||||
BuildContext context,
|
||||
RenderAssetGridRow row,
|
||||
bool scrolling,
|
||||
) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.maxWidth / widget.assetsPerRow -
|
||||
widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow;
|
||||
return Row(
|
||||
key: Key("asset-row-${row.assets.first.id}"),
|
||||
children: row.assets.mapIndexed((int index, Asset asset) {
|
||||
bool last = asset.id == row.assets.last.id;
|
||||
|
||||
return Container(
|
||||
key: Key("asset-${asset.id}"),
|
||||
width: size * row.widthDistribution[index],
|
||||
height: size,
|
||||
margin: EdgeInsets.only(
|
||||
top: widget.margin,
|
||||
right: last ? 0.0 : widget.margin,
|
||||
),
|
||||
child: _buildThumbnailOrPlaceholder(asset, scrolling),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(
|
||||
BuildContext context,
|
||||
String title,
|
||||
List<Asset> assets,
|
||||
) {
|
||||
return GroupDividerTitle(
|
||||
text: title,
|
||||
multiselectEnabled: widget.selectionActive,
|
||||
onSelect: () => _selectAssets(assets),
|
||||
onDeselect: () => _deselectAssets(assets),
|
||||
selected: _allAssetsSelected(assets),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthTitle(BuildContext context, String title) {
|
||||
return Padding(
|
||||
key: Key("month-$title"),
|
||||
padding: const EdgeInsets.only(left: 12.0, top: 32),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).textTheme.displayLarge?.color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _itemBuilder(BuildContext c, int position) {
|
||||
final item = widget.renderList.elements[position];
|
||||
|
||||
if (item.type == RenderAssetGridElementType.groupDividerTitle) {
|
||||
return _buildTitle(c, item.title!, item.relatedAssetList!);
|
||||
} else if (item.type == RenderAssetGridElementType.monthTitle) {
|
||||
return _buildMonthTitle(c, item.title!);
|
||||
} else if (item.type == RenderAssetGridElementType.assetRow) {
|
||||
return _buildAssetRow(c, item.assetRow!, _scrolling);
|
||||
}
|
||||
|
||||
return const Text("Invalid widget type!");
|
||||
}
|
||||
|
||||
Text _labelBuilder(int pos) {
|
||||
final date = widget.renderList.elements[pos].date;
|
||||
return Text(
|
||||
DateFormat.yMMMM().format(date),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMultiSelectIndicator() {
|
||||
return DisableMultiSelectButton(
|
||||
onPressed: () => _deselectAll(),
|
||||
selectedItemCount: _selectedAssets.length,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssetGrid() {
|
||||
final useDragScrolling = widget.allAssets.length >= 20;
|
||||
|
||||
void dragScrolling(bool active) {
|
||||
setState(() {
|
||||
_scrolling = active;
|
||||
});
|
||||
}
|
||||
|
||||
final listWidget = ScrollablePositionedList.builder(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 220,
|
||||
),
|
||||
itemBuilder: _itemBuilder,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
itemScrollController: _itemScrollController,
|
||||
itemCount: widget.renderList.elements.length,
|
||||
addRepaintBoundaries: true,
|
||||
);
|
||||
|
||||
final child = useDragScrolling
|
||||
? DraggableScrollbar.semicircle(
|
||||
scrollStateListener: dragScrolling,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
controller: _itemScrollController,
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
labelTextBuilder: _labelBuilder,
|
||||
labelConstraints: const BoxConstraints(maxHeight: 28),
|
||||
scrollbarAnimationDuration: const Duration(seconds: 1),
|
||||
scrollbarTimeToFade: const Duration(seconds: 4),
|
||||
child: listWidget,
|
||||
)
|
||||
: listWidget;
|
||||
|
||||
return widget.onRefresh == null
|
||||
? child
|
||||
: RefreshIndicator(onRefresh: widget.onRefresh!, child: child);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ImmichAssetGridView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!widget.selectionActive) {
|
||||
setState(() {
|
||||
_selectedAssets.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> onWillPop() async {
|
||||
if (widget.selectionActive && _selectedAssets.isNotEmpty) {
|
||||
_deselectAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
scrollToTopNotifierProvider.addListener(_scrollToTop);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollToTopNotifierProvider.removeListener(_scrollToTop);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToTop() {
|
||||
// for some reason, this is necessary as well in order
|
||||
// to correctly reposition the drag thumb scroll bar
|
||||
_itemScrollController.jumpTo(
|
||||
index: 0,
|
||||
);
|
||||
_itemScrollController.scrollTo(
|
||||
index: 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildAssetGrid(),
|
||||
if (widget.selectionActive) _buildMultiSelectIndicator(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImmichAssetGridView extends StatefulWidget {
|
||||
final RenderList renderList;
|
||||
final int assetsPerRow;
|
||||
final double margin;
|
||||
final bool showStorageIndicator;
|
||||
final ImmichAssetGridSelectionListener? listener;
|
||||
final bool selectionActive;
|
||||
final List<Asset> allAssets;
|
||||
final Future<void> Function()? onRefresh;
|
||||
|
||||
const ImmichAssetGridView({
|
||||
super.key,
|
||||
required this.renderList,
|
||||
required this.allAssets,
|
||||
required this.assetsPerRow,
|
||||
required this.showStorageIndicator,
|
||||
this.listener,
|
||||
this.margin = 5.0,
|
||||
this.selectionActive = false,
|
||||
this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return ImmichAssetGridViewState();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
@@ -32,8 +31,6 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||
|
||||
Widget buildSelectionIcon(Asset asset) {
|
||||
if (isSelected) {
|
||||
return Icon(
|
||||
@@ -70,6 +67,31 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
HapticFeedback.heavyImpact();
|
||||
},
|
||||
child: Hero(
|
||||
createRectTween: (begin, end) {
|
||||
double? top;
|
||||
// Uses the [BoxFit.contain] algorithm
|
||||
if (asset.width != null && asset.height != null) {
|
||||
final assetAR = asset.width! / asset.height!;
|
||||
final w = MediaQuery.of(context).size.width;
|
||||
final deviceAR = MediaQuery.of(context).size.aspectRatio;
|
||||
if (deviceAR < assetAR) {
|
||||
top = asset.height! * w / asset.width!;
|
||||
} else {
|
||||
top = 0;
|
||||
}
|
||||
// get the height offset
|
||||
}
|
||||
|
||||
return MaterialRectCenterArcTween(
|
||||
begin: Rect.fromLTRB(
|
||||
0,
|
||||
top ?? 0.0,
|
||||
MediaQuery.of(context).size.width,
|
||||
MediaQuery.of(context).size.height,
|
||||
),
|
||||
end: end,
|
||||
);
|
||||
},
|
||||
tag: asset.id,
|
||||
child: Stack(
|
||||
children: [
|
||||
@@ -103,7 +125,7 @@ class ThumbnailImage extends HookConsumerWidget {
|
||||
bottom: 5,
|
||||
child: Icon(
|
||||
asset.isRemote
|
||||
? (deviceId == asset.deviceId
|
||||
? (asset.isLocal
|
||||
? Icons.cloud_done_outlined
|
||||
: Icons.cloud_outlined)
|
||||
: Icons.cloud_off_outlined,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
|
||||
@@ -13,7 +10,6 @@ import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/transparent_image.dart';
|
||||
|
||||
class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
@override
|
||||
@@ -29,8 +25,8 @@ class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final BackUpState backupState = ref.watch(backupProvider);
|
||||
bool isEnableAutoBackup = backupState.backgroundBackup ||
|
||||
ref.watch(authenticationProvider).deviceInfo.isAutoBackup;
|
||||
final bool isEnableAutoBackup =
|
||||
backupState.backgroundBackup || backupState.autoBackup;
|
||||
final ServerInfoState serverInfoState = ref.watch(serverInfoProvider);
|
||||
AuthenticationState authState = ref.watch(authenticationProvider);
|
||||
|
||||
@@ -47,27 +43,13 @@ class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
},
|
||||
);
|
||||
} else {
|
||||
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
var dummy = Random().nextInt(1024);
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Scaffold.of(context).openDrawer();
|
||||
},
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
child: const UserCircleAvatar(
|
||||
radius: 18,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: FadeInImage.memoryNetwork(
|
||||
fit: BoxFit.cover,
|
||||
placeholder: kTransparentImage,
|
||||
width: 33,
|
||||
height: 33,
|
||||
image:
|
||||
'$endpoint/user/profile-image/${authState.userId}?d=${dummy++}',
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
),
|
||||
),
|
||||
size: 33,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/transparent_image.dart';
|
||||
|
||||
class ProfileDrawerHeader extends HookConsumerWidget {
|
||||
const ProfileDrawerHeader({
|
||||
@@ -19,29 +15,15 @@ class ProfileDrawerHeader extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
String endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
|
||||
AuthenticationState authState = ref.watch(authenticationProvider);
|
||||
final uploadProfileImageStatus =
|
||||
ref.watch(uploadProfileImageProvider).status;
|
||||
var dummy = Random().nextInt(1024);
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
buildUserProfileImage() {
|
||||
var userImage = CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
var userImage = const UserCircleAvatar(
|
||||
radius: 35,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: FadeInImage.memoryNetwork(
|
||||
fit: BoxFit.cover,
|
||||
placeholder: kTransparentImage,
|
||||
width: 66,
|
||||
height: 66,
|
||||
image:
|
||||
'$endpoint/user/profile-image/${authState.userId}?d=${dummy++}',
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
),
|
||||
),
|
||||
size: 66,
|
||||
);
|
||||
|
||||
if (authState.profileImagePath.isEmpty) {
|
||||
|
||||
@@ -106,7 +106,9 @@ class ServerInfoBox extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}",
|
||||
serverInfoState.serverVersion.major > 0
|
||||
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch_}"
|
||||
: "?",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
|
||||
44
mobile/lib/modules/home/ui/user_circle_avatar.dart
Normal file
44
mobile/lib/modules/home/ui/user_circle_avatar.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/ui/transparent_image.dart';
|
||||
|
||||
class UserCircleAvatar extends ConsumerWidget {
|
||||
final double radius;
|
||||
final double size;
|
||||
const UserCircleAvatar({super.key, required this.radius, required this.size});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
AuthenticationState authState = ref.watch(authenticationProvider);
|
||||
|
||||
var profileImageUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${authState.userId}?d=${Random().nextInt(1024)}';
|
||||
return CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
radius: radius,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: FadeInImage(
|
||||
fit: BoxFit.cover,
|
||||
placeholder: MemoryImage(kTransparentImage),
|
||||
width: size,
|
||||
height: size,
|
||||
image: NetworkImage(
|
||||
profileImageUrl,
|
||||
headers: {
|
||||
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"
|
||||
},
|
||||
),
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageErrorBuilder: (context, error, stackTrace) =>
|
||||
Image.memory(kTransparentImage),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,12 @@ class HomePage extends HookConsumerWidget {
|
||||
final selectionEnabledHook = useState(false);
|
||||
|
||||
final selection = useState(<Asset>{});
|
||||
final albums = ref.watch(albumProvider);
|
||||
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
final albumService = ref.watch(albumServiceProvider);
|
||||
|
||||
final tipOneOpacity = useState(0.0);
|
||||
final refreshCount = useState(0);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
@@ -182,6 +183,22 @@ class HomePage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshAssets() async {
|
||||
debugPrint("refreshCount.value ${refreshCount.value}");
|
||||
final fullRefresh = refreshCount.value > 0;
|
||||
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
|
||||
if (fullRefresh) {
|
||||
// refresh was forced: user requested another refresh within 2 seconds
|
||||
refreshCount.value = 0;
|
||||
} else {
|
||||
refreshCount.value++;
|
||||
// set counter back to 0 if user does not request refresh again
|
||||
Timer(const Duration(seconds: 2), () {
|
||||
refreshCount.value = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
buildLoadingIndicator() {
|
||||
Timer(const Duration(seconds: 2), () {
|
||||
tipOneOpacity.value = 1;
|
||||
@@ -234,13 +251,14 @@ class HomePage extends HookConsumerWidget {
|
||||
? buildLoadingIndicator()
|
||||
: ImmichAssetGrid(
|
||||
renderList: ref.watch(assetProvider).renderList!,
|
||||
allAssets: ref.watch(assetProvider).allAssets,
|
||||
assets: ref.watch(assetProvider).allAssets,
|
||||
assetsPerRow: appSettingService
|
||||
.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
showStorageIndicator: appSettingService
|
||||
.getSetting(AppSettingsEnum.storageIndicator),
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
onRefresh: refreshAssets,
|
||||
),
|
||||
if (selectionEnabledHook.value)
|
||||
SafeArea(
|
||||
|
||||
@@ -11,7 +11,6 @@ class AuthenticationState {
|
||||
final bool isAdmin;
|
||||
final bool shouldChangePassword;
|
||||
final String profileImagePath;
|
||||
final DeviceInfoResponseDto deviceInfo;
|
||||
AuthenticationState({
|
||||
required this.deviceId,
|
||||
required this.deviceType,
|
||||
@@ -23,7 +22,6 @@ class AuthenticationState {
|
||||
required this.isAdmin,
|
||||
required this.shouldChangePassword,
|
||||
required this.profileImagePath,
|
||||
required this.deviceInfo,
|
||||
});
|
||||
|
||||
AuthenticationState copyWith({
|
||||
@@ -37,7 +35,6 @@ class AuthenticationState {
|
||||
bool? isAdmin,
|
||||
bool? shouldChangePassword,
|
||||
String? profileImagePath,
|
||||
DeviceInfoResponseDto? deviceInfo,
|
||||
}) {
|
||||
return AuthenticationState(
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
@@ -50,13 +47,12 @@ class AuthenticationState {
|
||||
isAdmin: isAdmin ?? this.isAdmin,
|
||||
shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
|
||||
profileImagePath: profileImagePath ?? this.profileImagePath,
|
||||
deviceInfo: deviceInfo ?? this.deviceInfo,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath, deviceInfo: $deviceInfo)';
|
||||
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -73,8 +69,7 @@ class AuthenticationState {
|
||||
other.lastName == lastName &&
|
||||
other.isAdmin == isAdmin &&
|
||||
other.shouldChangePassword == shouldChangePassword &&
|
||||
other.profileImagePath == profileImagePath &&
|
||||
other.deviceInfo == deviceInfo;
|
||||
other.profileImagePath == profileImagePath;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -88,7 +83,6 @@ class AuthenticationState {
|
||||
lastName.hashCode ^
|
||||
isAdmin.hashCode ^
|
||||
shouldChangePassword.hashCode ^
|
||||
profileImagePath.hashCode ^
|
||||
deviceInfo.hashCode;
|
||||
profileImagePath.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
AuthenticationNotifier(
|
||||
this._deviceInfoService,
|
||||
this._backupService,
|
||||
this._apiService,
|
||||
this._assetCacheService,
|
||||
this._albumCacheService,
|
||||
this._sharedAlbumCacheService,
|
||||
) : super(
|
||||
AuthenticationState(
|
||||
deviceId: "",
|
||||
@@ -34,23 +28,11 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
isAdmin: false,
|
||||
shouldChangePassword: false,
|
||||
isAuthenticated: false,
|
||||
deviceInfo: DeviceInfoResponseDto(
|
||||
id: 0,
|
||||
userId: "",
|
||||
deviceId: "",
|
||||
deviceType: DeviceTypeEnum.ANDROID,
|
||||
createdAt: "",
|
||||
isAutoBackup: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final DeviceInfoService _deviceInfoService;
|
||||
final BackupService _backupService;
|
||||
final ApiService _apiService;
|
||||
final AssetCacheService _assetCacheService;
|
||||
final AlbumCacheService _albumCacheService;
|
||||
final SharedAlbumCacheService _sharedAlbumCacheService;
|
||||
|
||||
Future<bool> login(
|
||||
String email,
|
||||
@@ -95,13 +77,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
try {
|
||||
await Future.wait([
|
||||
_apiService.authenticationApi.logout(),
|
||||
Hive.box(userInfoBox).delete(accessTokenKey),
|
||||
Store.delete(StoreKey.assetETag),
|
||||
Store.delete(StoreKey.userRemoteId),
|
||||
_assetCacheService.invalidate(),
|
||||
_albumCacheService.invalidate(),
|
||||
_sharedAlbumCacheService.invalidate(),
|
||||
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey)
|
||||
Store.delete(StoreKey.currentUser),
|
||||
Store.delete(StoreKey.accessToken),
|
||||
]);
|
||||
|
||||
state = state.copyWith(isAuthenticated: false);
|
||||
@@ -113,18 +91,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
}
|
||||
}
|
||||
|
||||
setAutoBackup(bool backupState) async {
|
||||
var deviceInfo = await _deviceInfoService.getDeviceInfo();
|
||||
var deviceId = deviceInfo["deviceId"];
|
||||
|
||||
DeviceTypeEnum deviceType = deviceInfo["deviceType"];
|
||||
|
||||
DeviceInfoResponseDto updatedDeviceInfo =
|
||||
await _backupService.setAutoBackup(backupState, deviceId, deviceType);
|
||||
|
||||
state = state.copyWith(deviceInfo: updatedDeviceInfo);
|
||||
}
|
||||
|
||||
updateUserProfileImagePath(String path) {
|
||||
state = state.copyWith(profileImagePath: path);
|
||||
}
|
||||
@@ -153,14 +119,22 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
required String serverUrl,
|
||||
}) async {
|
||||
_apiService.setAccessToken(accessToken);
|
||||
var userResponseDto = await _apiService.userApi.getMyUserInfo();
|
||||
UserResponseDto? userResponseDto;
|
||||
try {
|
||||
userResponseDto = await _apiService.userApi.getMyUserInfo();
|
||||
} on ApiException catch (e) {
|
||||
if (e.innerException is SocketException) {
|
||||
state = state.copyWith(isAuthenticated: true);
|
||||
}
|
||||
}
|
||||
|
||||
if (userResponseDto != null) {
|
||||
var userInfoHiveBox = await Hive.openBox(userInfoBox);
|
||||
var deviceInfo = await _deviceInfoService.getDeviceInfo();
|
||||
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
|
||||
userInfoHiveBox.put(accessTokenKey, accessToken);
|
||||
Store.put(StoreKey.userRemoteId, userResponseDto.id);
|
||||
Store.put(StoreKey.deviceId, deviceInfo["deviceId"]);
|
||||
Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"]));
|
||||
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
|
||||
Store.put(StoreKey.serverUrl, serverUrl);
|
||||
Store.put(StoreKey.accessToken, accessToken);
|
||||
|
||||
state = state.copyWith(
|
||||
isAuthenticated: true,
|
||||
@@ -174,40 +148,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
deviceId: deviceInfo["deviceId"],
|
||||
deviceType: deviceInfo["deviceType"],
|
||||
);
|
||||
|
||||
// Save login info to local storage
|
||||
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
|
||||
savedLoginInfoKey,
|
||||
HiveSavedLoginInfo(
|
||||
email: "",
|
||||
password: "",
|
||||
serverUrl: serverUrl,
|
||||
accessToken: accessToken,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Register device info
|
||||
try {
|
||||
DeviceInfoResponseDto? deviceInfo =
|
||||
await _apiService.deviceInfoApi.upsertDeviceInfo(
|
||||
UpsertDeviceInfoDto(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -216,10 +157,6 @@ final authenticationProvider =
|
||||
StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
|
||||
return AuthenticationNotifier(
|
||||
ref.watch(deviceInfoServiceProvider),
|
||||
ref.watch(backupServiceProvider),
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(assetCacheServiceProvider),
|
||||
ref.watch(albumCacheServiceProvider),
|
||||
ref.watch(sharedAlbumCacheServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/providers/oauth.provider.dart';
|
||||
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
@@ -32,58 +30,81 @@ class LoginForm extends HookConsumerWidget {
|
||||
final serverEndpointController =
|
||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final apiService = ref.watch(apiServiceProvider);
|
||||
final emailFocusNode = useFocusNode();
|
||||
final passwordFocusNode = useFocusNode();
|
||||
final serverEndpointFocusNode = useFocusNode();
|
||||
final isLoading = useState<bool>(false);
|
||||
final isLoadingServer = useState<bool>(false);
|
||||
final isOauthEnable = useState<bool>(false);
|
||||
final oAuthButtonLabel = useState<String>('OAuth');
|
||||
final logoAnimationController = useAnimationController(
|
||||
duration: const Duration(seconds: 60),
|
||||
)..repeat();
|
||||
|
||||
getServeLoginConfig() async {
|
||||
if (!serverEndpointFocusNode.hasFocus) {
|
||||
var serverUrl = serverEndpointController.text.trim();
|
||||
final ValueNotifier<String?> serverEndpoint = useState<String?>(null);
|
||||
|
||||
try {
|
||||
if (serverUrl.isNotEmpty) {
|
||||
isLoading.value = true;
|
||||
final serverEndpoint =
|
||||
await apiService.resolveAndSetEndpoint(serverUrl.toString());
|
||||
/// Fetch the server login credential and enables oAuth login if necessary
|
||||
/// Returns true if successful, false otherwise
|
||||
Future<bool> getServerLoginCredential() async {
|
||||
final serverUrl = serverEndpointController.text.trim();
|
||||
|
||||
var loginConfig = await apiService.oAuthApi.generateConfig(
|
||||
OAuthConfigDto(redirectUri: serverEndpoint),
|
||||
);
|
||||
// Guard empty URL
|
||||
if (serverUrl.isEmpty) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_form_server_empty".tr(),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
|
||||
if (loginConfig != null) {
|
||||
isOauthEnable.value = loginConfig.enabled;
|
||||
oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth';
|
||||
} else {
|
||||
isOauthEnable.value = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isLoading.value = false;
|
||||
}
|
||||
} catch (_) {
|
||||
isLoading.value = false;
|
||||
try {
|
||||
isLoadingServer.value = true;
|
||||
final endpoint = await apiService.resolveAndSetEndpoint(serverUrl);
|
||||
|
||||
final loginConfig = await apiService.oAuthApi.generateConfig(
|
||||
OAuthConfigDto(redirectUri: serverUrl),
|
||||
);
|
||||
|
||||
if (loginConfig != null) {
|
||||
isOauthEnable.value = loginConfig.enabled;
|
||||
oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth';
|
||||
} else {
|
||||
isOauthEnable.value = false;
|
||||
}
|
||||
|
||||
serverEndpoint.value = endpoint;
|
||||
} on ApiException catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: e.message ?? 'login_form_api_exception'.tr(),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
isOauthEnable.value = false;
|
||||
isLoadingServer.value = false;
|
||||
return false;
|
||||
} catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'login_form_server_error'.tr(),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
isOauthEnable.value = false;
|
||||
isLoadingServer.value = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
isLoadingServer.value = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
serverEndpointFocusNode.addListener(getServeLoginConfig);
|
||||
|
||||
var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
|
||||
.get(savedLoginInfoKey);
|
||||
|
||||
if (loginInfo != null) {
|
||||
usernameController.text = loginInfo.email;
|
||||
passwordController.text = loginInfo.password;
|
||||
serverEndpointController.text = loginInfo.serverUrl;
|
||||
final serverUrl = Store.tryGet(StoreKey.serverUrl);
|
||||
if (serverUrl != null) {
|
||||
serverEndpointController.text = serverUrl;
|
||||
}
|
||||
|
||||
getServeLoginConfig();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
@@ -95,215 +116,20 @@ class LoginForm extends HookConsumerWidget {
|
||||
serverEndpointController.text = 'http://10.1.15.216:2283/api';
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: SingleChildScrollView(
|
||||
child: AutofillGroup(
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onDoubleTap: () => populateTestLoginInfo(),
|
||||
child: RotationTransition(
|
||||
turns: logoAnimationController,
|
||||
child: const ImmichLogo(
|
||||
heroTag: 'logo',
|
||||
),
|
||||
),
|
||||
),
|
||||
const ImmichTitleText(),
|
||||
EmailInput(controller: usernameController),
|
||||
PasswordInput(controller: passwordController),
|
||||
ServerEndpointInput(
|
||||
controller: serverEndpointController,
|
||||
focusNode: serverEndpointFocusNode,
|
||||
),
|
||||
if (isLoading.value)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
if (!isLoading.value)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 18),
|
||||
LoginButton(
|
||||
emailController: usernameController,
|
||||
passwordController: passwordController,
|
||||
serverEndpointController: serverEndpointController,
|
||||
),
|
||||
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,
|
||||
buttonLabel: oAuthButtonLabel.value,
|
||||
isLoading: isLoading,
|
||||
onLoginSuccess: () {
|
||||
isLoading.value = false;
|
||||
final permission = ref.watch(galleryPermissionNotifier);
|
||||
if (permission.isGranted || permission.isLimited) {
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
}
|
||||
AutoRouter.of(context).replace(
|
||||
const TabControllerRoute(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
login() async {
|
||||
// Start loading
|
||||
isLoading.value = true;
|
||||
|
||||
class ServerEndpointInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
const ServerEndpointInput({
|
||||
Key? key,
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
}) : super(key: key);
|
||||
// This will remove current cache asset state of previous user login.
|
||||
ref.read(assetProvider.notifier).clearAllAsset();
|
||||
|
||||
String? _validateInput(String? url) {
|
||||
if (url == null || url.isEmpty) return null;
|
||||
|
||||
final parsedUrl = Uri.tryParse(sanitizeUrl(url));
|
||||
if (parsedUrl == null ||
|
||||
!parsedUrl.isAbsolute ||
|
||||
!parsedUrl.scheme.startsWith("http") ||
|
||||
parsedUrl.host.isEmpty) {
|
||||
return 'login_form_err_invalid_url'.tr();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'login_form_endpoint_url'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'login_form_endpoint_hint'.tr(),
|
||||
errorMaxLines: 4,
|
||||
),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
focusNode: focusNode,
|
||||
autofillHints: const [AutofillHints.url],
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EmailInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
|
||||
const EmailInput({Key? key, required this.controller}) : super(key: key);
|
||||
|
||||
String? _validateInput(String? email) {
|
||||
if (email == null || email == '') return null;
|
||||
if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr();
|
||||
if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr();
|
||||
if (email.contains(' ') || !email.contains('@')) {
|
||||
return 'login_form_err_invalid_email'.tr();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'login_form_label_email'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'login_form_email_hint'.tr(),
|
||||
),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
|
||||
const PasswordInput({Key? key, required this.controller}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
obscureText: true,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'login_form_label_password'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'login_form_password_hint'.tr(),
|
||||
),
|
||||
autofillHints: const [AutofillHints.password],
|
||||
keyboardType: TextInputType.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginButton extends ConsumerWidget {
|
||||
final TextEditingController emailController;
|
||||
final TextEditingController passwordController;
|
||||
final TextEditingController serverEndpointController;
|
||||
|
||||
const LoginButton({
|
||||
Key? key,
|
||||
required this.emailController,
|
||||
required this.passwordController,
|
||||
required this.serverEndpointController,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: () async {
|
||||
// This will remove current cache asset state of previous user login.
|
||||
ref.read(assetProvider.notifier).clearAllAsset();
|
||||
|
||||
var isAuthenticated =
|
||||
try {
|
||||
final isAuthenticated =
|
||||
await ref.read(authenticationProvider.notifier).login(
|
||||
emailController.text,
|
||||
usernameController.text,
|
||||
passwordController.text,
|
||||
serverEndpointController.text,
|
||||
serverEndpointController.text.trim(),
|
||||
);
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Resume backup (if enable) then navigate
|
||||
if (ref.read(authenticationProvider).shouldChangePassword &&
|
||||
@@ -326,35 +152,14 @@ class LoginButton extends ConsumerWidget {
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.login_rounded),
|
||||
label: const Text(
|
||||
"login_form_button_text",
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Make sure we stop loading
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
class OAuthLoginButton extends ConsumerWidget {
|
||||
final TextEditingController serverEndpointController;
|
||||
final ValueNotifier<bool> isLoading;
|
||||
final VoidCallback onLoginSuccess;
|
||||
final String buttonLabel;
|
||||
|
||||
const OAuthLoginButton({
|
||||
Key? key,
|
||||
required this.serverEndpointController,
|
||||
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 {
|
||||
oAuthLogin() async {
|
||||
var oAuthService = ref.watch(oAuthServiceProvider);
|
||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||
OAuthConfigResponseDto? oAuthServerConfig;
|
||||
|
||||
@@ -387,7 +192,13 @@ class OAuthLoginButton extends ConsumerWidget {
|
||||
|
||||
if (isSuccess) {
|
||||
isLoading.value = false;
|
||||
onLoginSuccess();
|
||||
final permission = ref.watch(galleryPermissionNotifier);
|
||||
if (permission.isGranted || permission.isLimited) {
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
}
|
||||
AutoRouter.of(context).replace(
|
||||
const TabControllerRoute(),
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
@@ -409,12 +220,337 @@ class OAuthLoginButton extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
buildSelectServer() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ServerEndpointInput(
|
||||
controller: serverEndpointController,
|
||||
focusNode: serverEndpointFocusNode,
|
||||
onSubmit: getServerLoginCredential,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: isLoadingServer.value ? null : getServerLoginCredential,
|
||||
icon: const Icon(Icons.arrow_forward_rounded),
|
||||
label: const Text(
|
||||
'login_form_next_button',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
if (isLoadingServer.value)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 18.0),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
buildLogin() {
|
||||
return AutofillGroup(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
serverEndpointController.text,
|
||||
style: Theme.of(context).textTheme.displaySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
EmailInput(
|
||||
controller: usernameController,
|
||||
focusNode: emailFocusNode,
|
||||
onSubmit: passwordFocusNode.requestFocus,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
PasswordInput(
|
||||
controller: passwordController,
|
||||
focusNode: passwordFocusNode,
|
||||
onSubmit: login,
|
||||
),
|
||||
|
||||
// Note: This used to have an AnimatedSwitcher, but was removed
|
||||
// because of https://github.com/flutter/flutter/issues/120874
|
||||
isLoading.value
|
||||
? const Padding(
|
||||
padding: EdgeInsets.only(top: 18.0),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: FittedBox(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 18),
|
||||
LoginButton(onPressed: login),
|
||||
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,
|
||||
buttonLabel: oAuthButtonLabel.value,
|
||||
isLoading: isLoading,
|
||||
onPressed: oAuthLogin,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => serverEndpoint.value = null,
|
||||
label: const Text('Back'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final serverSelectionOrLogin =
|
||||
serverEndpoint.value == null ? buildSelectServer() : buildLogin();
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: constraints.maxHeight / 5,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onDoubleTap: () => populateTestLoginInfo(),
|
||||
child: RotationTransition(
|
||||
turns: logoAnimationController,
|
||||
child: const ImmichLogo(
|
||||
heroTag: 'logo',
|
||||
),
|
||||
),
|
||||
),
|
||||
const ImmichTitleText(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Note: This used to have an AnimatedSwitcher, but was removed
|
||||
// because of https://github.com/flutter/flutter/issues/120874
|
||||
serverSelectionOrLogin,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ServerEndpointInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final Function()? onSubmit;
|
||||
|
||||
const ServerEndpointInput({
|
||||
Key? key,
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
this.onSubmit,
|
||||
}) : super(key: key);
|
||||
|
||||
String? _validateInput(String? url) {
|
||||
if (url == null || url.isEmpty) return null;
|
||||
|
||||
final parsedUrl = Uri.tryParse(sanitizeUrl(url));
|
||||
if (parsedUrl == null ||
|
||||
!parsedUrl.isAbsolute ||
|
||||
!parsedUrl.scheme.startsWith("http") ||
|
||||
parsedUrl.host.isEmpty) {
|
||||
return 'login_form_err_invalid_url'.tr();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'login_form_endpoint_url'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'login_form_endpoint_hint'.tr(),
|
||||
errorMaxLines: 4,
|
||||
),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
focusNode: focusNode,
|
||||
autofillHints: const [AutofillHints.url],
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
onFieldSubmitted: (_) => onSubmit?.call(),
|
||||
textInputAction: TextInputAction.go,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class EmailInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final Function()? onSubmit;
|
||||
|
||||
const EmailInput({
|
||||
Key? key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.onSubmit,
|
||||
}) : super(key: key);
|
||||
|
||||
String? _validateInput(String? email) {
|
||||
if (email == null || email == '') return null;
|
||||
if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr();
|
||||
if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr();
|
||||
if (email.contains(' ') || !email.contains('@')) {
|
||||
return 'login_form_err_invalid_email'.tr();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
autofocus: true,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'login_form_label_email'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'login_form_email_hint'.tr(),
|
||||
hintStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
onFieldSubmitted: (_) => onSubmit?.call(),
|
||||
focusNode: focusNode,
|
||||
textInputAction: TextInputAction.next,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final Function()? onSubmit;
|
||||
|
||||
const PasswordInput({
|
||||
Key? key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.onSubmit,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
obscureText: true,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'login_form_label_password'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'login_form_password_hint'.tr(),
|
||||
hintStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
autofillHints: const [AutofillHints.password],
|
||||
keyboardType: TextInputType.text,
|
||||
onFieldSubmitted: (_) => onSubmit?.call(),
|
||||
focusNode: focusNode,
|
||||
textInputAction: TextInputAction.go,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginButton extends ConsumerWidget {
|
||||
final Function() onPressed;
|
||||
|
||||
const LoginButton({
|
||||
Key? key,
|
||||
required this.onPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: const Icon(Icons.login_rounded),
|
||||
label: const Text(
|
||||
"login_form_button_text",
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OAuthLoginButton extends ConsumerWidget {
|
||||
final TextEditingController serverEndpointController;
|
||||
final ValueNotifier<bool> isLoading;
|
||||
final String buttonLabel;
|
||||
final Function() onPressed;
|
||||
|
||||
const OAuthLoginButton({
|
||||
Key? key,
|
||||
required this.serverEndpointController,
|
||||
required this.isLoading,
|
||||
required this.buttonLabel,
|
||||
required this.onPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).primaryColor.withAlpha(230),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: performOAuthLogin,
|
||||
onPressed: onPressed,
|
||||
icon: const Icon(Icons.pin_rounded),
|
||||
label: Text(
|
||||
buttonLabel,
|
||||
|
||||
15
mobile/lib/modules/search/models/curated_content.dart
Normal file
15
mobile/lib/modules/search/models/curated_content.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
/// A wrapper for [CuratedLocationsResponseDto] objects
|
||||
/// and [CuratedObjectsResponseDto] to be displayed in
|
||||
/// a view
|
||||
class CuratedContent {
|
||||
/// The label to show associated with this curated object
|
||||
final String label;
|
||||
|
||||
/// The id to lookup the asset from the server
|
||||
final String id;
|
||||
|
||||
CuratedContent({
|
||||
required this.id,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class SearchResultPageState {
|
||||
final bool isLoading;
|
||||
@@ -31,34 +28,6 @@ class SearchResultPageState {
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'isLoading': isLoading,
|
||||
'isSuccess': isSuccess,
|
||||
'isError': isError,
|
||||
'searchResult': searchResult.map((x) => x.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
factory SearchResultPageState.fromMap(Map<String, dynamic> map) {
|
||||
return SearchResultPageState(
|
||||
isLoading: map['isLoading'] ?? false,
|
||||
isSuccess: map['isSuccess'] ?? false,
|
||||
isError: map['isError'] ?? false,
|
||||
searchResult: List.from(
|
||||
map['searchResult']
|
||||
.map(AssetResponseDto.fromJson)
|
||||
.where((e) => e != null)
|
||||
.map(Asset.remote),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory SearchResultPageState.fromJson(String source) =>
|
||||
SearchResultPageState.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
|
||||
final allMotionPhotosProvider = FutureProvider<List<Asset>>( (ref) async {
|
||||
final search = await ref.watch(apiServiceProvider).searchApi.search(
|
||||
motion: true,
|
||||
);
|
||||
|
||||
if (search == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ref.watch(dbProvider)
|
||||
.assets
|
||||
.getAllByRemoteId(
|
||||
search.assets.items.map((e) => e.id),
|
||||
);
|
||||
|
||||
|
||||
/// This works offline, but we use the above
|
||||
/*
|
||||
return ref.watch(dbProvider).assets
|
||||
.filter()
|
||||
.livePhotoVideoIdIsNotNull()
|
||||
.findAll();
|
||||
*/
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
|
||||
final allVideoAssetsProvider = FutureProvider<List<Asset>>( (ref) async {
|
||||
final search = await ref.watch(apiServiceProvider).searchApi.search(
|
||||
type: 'VIDEO',
|
||||
);
|
||||
|
||||
if (search == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ref.watch(dbProvider)
|
||||
.assets
|
||||
.getAllByRemoteId(
|
||||
search.assets.items.map((e) => e.id),
|
||||
);
|
||||
|
||||
/// This works offline, but we use the above
|
||||
/*
|
||||
return ref.watch(dbProvider).assets
|
||||
.filter()
|
||||
.durationInSecondsGreaterThan(0)
|
||||
.findAll();
|
||||
*/
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
|
||||
final recentlyAddedProvider = FutureProvider<List<Asset>>( (ref) async {
|
||||
final search = await ref.watch(apiServiceProvider).searchApi.search(
|
||||
recent: true,
|
||||
);
|
||||
|
||||
if (search == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ref.watch(dbProvider)
|
||||
.assets
|
||||
.getAllByRemoteId(
|
||||
search.assets.items.map((e) => e.id),
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/models/search_result_page_state.model.dart';
|
||||
|
||||
import 'package:immich_mobile/modules/search/services/search.service.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
|
||||
@@ -20,7 +18,7 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
|
||||
|
||||
final SearchService _searchService;
|
||||
|
||||
void search(String searchTerm) async {
|
||||
void search(String searchTerm, {bool clipEnable = true}) async {
|
||||
state = state.copyWith(
|
||||
searchResult: [],
|
||||
isError: false,
|
||||
@@ -28,7 +26,10 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
|
||||
isSuccess: false,
|
||||
);
|
||||
|
||||
List<Asset>? assets = await _searchService.searchAsset(searchTerm);
|
||||
List<Asset>? assets = await _searchService.searchAsset(
|
||||
searchTerm,
|
||||
clipEnable: clipEnable,
|
||||
);
|
||||
|
||||
if (assets != null) {
|
||||
state = state.copyWith(
|
||||
@@ -55,15 +56,6 @@ final searchResultPageProvider =
|
||||
});
|
||||
|
||||
final searchRenderListProvider = FutureProvider((ref) {
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final assets = ref.watch(searchResultPageProvider).searchResult;
|
||||
|
||||
final layout = AssetGridLayoutParameters(
|
||||
settings.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
settings.getSetting(AppSettingsEnum.dynamicLayout),
|
||||
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
|
||||
);
|
||||
|
||||
return RenderList.fromAssets(assets, layout);
|
||||
return ref.watch(renderListProvider(assets));
|
||||
});
|
||||
|
||||
@@ -2,19 +2,23 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final searchServiceProvider = Provider(
|
||||
(ref) => SearchService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class SearchService {
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
|
||||
SearchService(this._apiService);
|
||||
SearchService(this._apiService, this._db);
|
||||
|
||||
Future<List<String>?> getUserSuggestedSearchTerms() async {
|
||||
try {
|
||||
@@ -25,14 +29,21 @@ class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Asset>?> searchAsset(String searchTerm) async {
|
||||
Future<List<Asset>?> searchAsset(
|
||||
String searchTerm, {
|
||||
bool clipEnable = true,
|
||||
}) async {
|
||||
// TODO search in local DB: 1. when offline, 2. to find local assets
|
||||
try {
|
||||
final List<AssetResponseDto>? results = await _apiService.assetApi
|
||||
.searchAsset(SearchAssetDto(searchTerm: searchTerm));
|
||||
final SearchResponseDto? results = await _apiService.searchApi.search(
|
||||
query: searchTerm,
|
||||
clip: clipEnable,
|
||||
);
|
||||
if (results == null) {
|
||||
return null;
|
||||
}
|
||||
return results.map((e) => Asset.remote(e)).toList();
|
||||
// TODO local DB might be out of date; add assets not yet in DB?
|
||||
return _db.assets.getAllByRemoteId(results.assets.items.map((e) => e.id));
|
||||
} catch (e) {
|
||||
debugPrint("[ERROR] [searchAsset] ${e.toString()}");
|
||||
return null;
|
||||
|
||||
66
mobile/lib/modules/search/ui/curated_row.dart
Normal file
66
mobile/lib/modules/search/ui/curated_row.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_content.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
|
||||
class CuratedRow extends StatelessWidget {
|
||||
final List<CuratedContent> content;
|
||||
final double imageSize;
|
||||
|
||||
/// Callback with the content and the index when tapped
|
||||
final Function(CuratedContent, int)? onTap;
|
||||
|
||||
const CuratedRow({
|
||||
super.key,
|
||||
required this.content,
|
||||
this.imageSize = 200,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Guard empty [content]
|
||||
if (content.isEmpty) {
|
||||
// Return empty thumbnail
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
child: ThumbnailWithInfo(
|
||||
textInfo: '',
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final object = content[index];
|
||||
final thumbnailRequestUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
|
||||
return SizedBox(
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 4.0),
|
||||
child: ThumbnailWithInfo(
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
textInfo: object.label,
|
||||
onTap: () => onTap?.call(object, index),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: content.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
55
mobile/lib/modules/search/ui/explore_grid.dart
Normal file
55
mobile/lib/modules/search/ui/explore_grid.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_content.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
|
||||
class ExploreGrid extends StatelessWidget {
|
||||
final List<CuratedContent> curatedContent;
|
||||
const ExploreGrid({
|
||||
super.key,
|
||||
required this.curatedContent,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (curatedContent.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
height: 100,
|
||||
width: 100,
|
||||
child: ThumbnailWithInfo(
|
||||
textInfo: '',
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 140,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final content = curatedContent[index];
|
||||
final thumbnailRequestUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${content.id}';
|
||||
return ThumbnailWithInfo(
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
textInfo: content.label,
|
||||
borderRadius: 0,
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(
|
||||
SearchResultRoute(searchTerm: 'm:${content.label}'),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
itemCount: curatedContent.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,10 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
)
|
||||
: const Icon(Icons.search_rounded),
|
||||
: const Icon(
|
||||
Icons.search_rounded,
|
||||
size: 20,
|
||||
),
|
||||
title: TextField(
|
||||
controller: searchTermController,
|
||||
focusNode: searchFocusNode,
|
||||
@@ -55,6 +58,8 @@ class SearchBar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
hintText: 'search_bar_hint'.tr(),
|
||||
hintStyle: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
enabledBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
|
||||
31
mobile/lib/modules/search/ui/search_result_grid.dart
Normal file
31
mobile/lib/modules/search/ui/search_result_grid.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
class SearchResultGrid extends HookConsumerWidget {
|
||||
const SearchResultGrid({super.key, required this.assets});
|
||||
|
||||
final List<Asset> assets;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
childAspectRatio: 1,
|
||||
crossAxisSpacing: 4,
|
||||
mainAxisSpacing: 4,
|
||||
),
|
||||
itemCount: assets.length,
|
||||
itemBuilder: (context, index) {
|
||||
final asset = assets[index];
|
||||
return ThumbnailImage(
|
||||
asset: asset,
|
||||
assetList: assets,
|
||||
useGrayBoxPlaceholder: true,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
@@ -12,6 +13,7 @@ class SearchSuggestionList extends ConsumerWidget {
|
||||
final searchTerm = ref.watch(searchPageStateProvider).searchTerm;
|
||||
final searchSuggestion =
|
||||
ref.watch(searchPageStateProvider).searchSuggestion;
|
||||
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
color: searchTerm.isEmpty
|
||||
@@ -19,13 +21,38 @@ class SearchSuggestionList extends ConsumerWidget {
|
||||
: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
color: isDarkTheme ? Colors.grey[800] : Colors.grey[100],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'search_suggestion_list_smart_search_hint_1'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
TextSpan(
|
||||
text: 'search_suggestion_list_smart_search_hint_2'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: true,
|
||||
child: ListView.builder(
|
||||
itemBuilder: ((context, index) {
|
||||
return ListTile(
|
||||
onTap: () {
|
||||
onSubmitted(searchSuggestion[index]);
|
||||
onSubmitted("m:${searchSuggestion[index]}");
|
||||
},
|
||||
title: Text(searchSuggestion[index]),
|
||||
);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class ThumbnailWithInfo extends StatelessWidget {
|
||||
const ThumbnailWithInfo({
|
||||
ThumbnailWithInfo({
|
||||
Key? key,
|
||||
required this.textInfo,
|
||||
this.imageUrl,
|
||||
this.noImageIcon,
|
||||
this.borderRadius = 10,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@@ -16,70 +18,77 @@ class ThumbnailWithInfo extends StatelessWidget {
|
||||
final String? imageUrl;
|
||||
final Function onTap;
|
||||
final IconData? noImageIcon;
|
||||
double borderRadius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
var textAndIconColor = isDarkMode ? Colors.grey[100] : Colors.grey[700];
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
onTap();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
|
||||
border: Border.all(
|
||||
color: isDarkMode ? Colors.grey[800]! : Colors.grey[400]!,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: imageUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: CachedNetworkImage(
|
||||
width: 250,
|
||||
height: 250,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl!,
|
||||
httpHeaders: {
|
||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||
},
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
noImageIcon ?? Icons.not_listed_location,
|
||||
color: textAndIconColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
left: 14,
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 3,
|
||||
child: Text(
|
||||
textInfo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
|
||||
),
|
||||
child: imageUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: CachedNetworkImage(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
imageUrl: imageUrl!,
|
||||
httpHeaders: {
|
||||
"Authorization":
|
||||
"Bearer ${Store.get(StoreKey.accessToken)}"
|
||||
},
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
noImageIcon ?? Icons.not_listed_location,
|
||||
color: textAndIconColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
color: Colors.white,
|
||||
gradient: LinearGradient(
|
||||
begin: FractionalOffset.topCenter,
|
||||
end: FractionalOffset.bottomCenter,
|
||||
colors: [
|
||||
Colors.grey.withOpacity(0.0),
|
||||
textInfo == ''
|
||||
? Colors.black.withOpacity(0.1)
|
||||
: Colors.black.withOpacity(0.5),
|
||||
],
|
||||
stops: const [0.0, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
left: 14,
|
||||
child: Text(
|
||||
textInfo == '' ? textInfo : textInfo.capitalizeFirstLetter(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
35
mobile/lib/modules/search/views/all_motion_videos_page.dart
Normal file
35
mobile/lib/modules/search/views/all_motion_videos_page.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/all_motion_photos.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class AllMotionPhotosPage extends HookConsumerWidget {
|
||||
const AllMotionPhotosPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final motionPhotos = ref.watch(allMotionPhotosProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('motion_photos_page_title').tr(),
|
||||
leading: IconButton(
|
||||
onPressed: () => AutoRouter.of(context).pop(),
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
),
|
||||
),
|
||||
body: motionPhotos.when(
|
||||
data: (assets) => ImmichAssetGrid(
|
||||
assets: assets,
|
||||
),
|
||||
error: (e, s) => Text(e.toString()),
|
||||
loading: () => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
mobile/lib/modules/search/views/all_videos_page.dart
Normal file
35
mobile/lib/modules/search/views/all_videos_page.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/all_video_assets.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class AllVideosPage extends HookConsumerWidget {
|
||||
const AllVideosPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final videos = ref.watch(allVideoAssetsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('all_videos_page_title').tr(),
|
||||
leading: IconButton(
|
||||
onPressed: () => AutoRouter.of(context).pop(),
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
),
|
||||
),
|
||||
body: videos.when(
|
||||
data: (assets) => ImmichAssetGrid(
|
||||
assets: assets,
|
||||
),
|
||||
error: (e, s) => Text(e.toString()),
|
||||
loading: () => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
mobile/lib/modules/search/views/curated_location_page.dart
Normal file
52
mobile/lib/modules/search/views/curated_location_page.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_content.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class CuratedLocationPage extends HookConsumerWidget {
|
||||
const CuratedLocationPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
AsyncValue<List<CuratedLocationsResponseDto>> curatedLocation =
|
||||
ref.watch(getCuratedLocationProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'curated_location_page_title',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
).tr(),
|
||||
leading: IconButton(
|
||||
onPressed: () => AutoRouter.of(context).pop(),
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
),
|
||||
),
|
||||
body: curatedLocation.when(
|
||||
loading: () => const Center(child: ImmichLoadingIndicator()),
|
||||
error: (err, stack) => Center(
|
||||
child: Text('Error: $err'),
|
||||
),
|
||||
data: (curatedLocations) => ExploreGrid(
|
||||
curatedContent: curatedLocations
|
||||
.map(
|
||||
(l) => CuratedContent(
|
||||
label: l.city,
|
||||
id: l.id,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
55
mobile/lib/modules/search/views/curated_object_page.dart
Normal file
55
mobile/lib/modules/search/views/curated_object_page.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_content.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class CuratedObjectPage extends HookConsumerWidget {
|
||||
const CuratedObjectPage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
AsyncValue<List<CuratedObjectsResponseDto>> curatedObjects =
|
||||
ref.watch(getCuratedObjectProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'curated_object_page_title',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
).tr(),
|
||||
leading: IconButton(
|
||||
onPressed: () => AutoRouter.of(context).pop(),
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
),
|
||||
),
|
||||
body: curatedObjects.when(
|
||||
loading: () => const Center(child: ImmichLoadingIndicator()),
|
||||
error: (err, stack) => Center(
|
||||
child: Text('Error: $err'),
|
||||
),
|
||||
data: (curatedLocations) => ExploreGrid(
|
||||
curatedContent: curatedLocations
|
||||
.map(
|
||||
(l) => CuratedContent(
|
||||
label: l.object.capitalizeFirstLetter(),
|
||||
id: l.id,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
mobile/lib/modules/search/views/recently_added_page.dart
Normal file
35
mobile/lib/modules/search/views/recently_added_page.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/recently_added.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class RecentlyAddedPage extends HookConsumerWidget {
|
||||
const RecentlyAddedPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final recents = ref.watch(recentlyAddedProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('recently_added_page_title').tr(),
|
||||
leading: IconButton(
|
||||
onPressed: () => AutoRouter.of(context).pop(),
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
),
|
||||
),
|
||||
body: recents.when(
|
||||
data: (searchResponse) => ImmichAssetGrid(
|
||||
assets: searchResponse,
|
||||
),
|
||||
error: (e, s) => Text(e.toString()),
|
||||
loading: () => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/search/models/curated_content.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/curated_row.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_bar.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/capitalize_first_letter.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
@@ -22,15 +20,21 @@ class SearchPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var box = Hive.box(userInfoBox);
|
||||
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
|
||||
AsyncValue<List<CuratedLocationsResponseDto>> curatedLocation =
|
||||
ref.watch(getCuratedLocationProvider);
|
||||
AsyncValue<List<CuratedObjectsResponseDto>> curatedObjects =
|
||||
ref.watch(getCuratedObjectProvider);
|
||||
|
||||
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
double imageSize = MediaQuery.of(context).size.width / 3;
|
||||
|
||||
TextStyle categoryTitleStyle = const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14.0,
|
||||
);
|
||||
|
||||
Color categoryIconColor = isDarkTheme ? Colors.white : Colors.black;
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
searchFocusNode = FocusNode();
|
||||
@@ -43,109 +47,72 @@ class SearchPage extends HookConsumerWidget {
|
||||
searchFocusNode.unfocus();
|
||||
ref.watch(searchPageStateProvider.notifier).disableSearch();
|
||||
|
||||
AutoRouter.of(context).push(SearchResultRoute(searchTerm: searchTerm));
|
||||
AutoRouter.of(context).push(
|
||||
SearchResultRoute(
|
||||
searchTerm: searchTerm,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildPlaces() {
|
||||
return curatedLocation.when(
|
||||
loading: () => SizedBox(
|
||||
height: imageSize,
|
||||
child: const Center(child: ImmichLoadingIndicator()),
|
||||
),
|
||||
error: (err, stack) => Text('Error: $err'),
|
||||
data: (curatedLocations) {
|
||||
return curatedLocations.isNotEmpty
|
||||
? SizedBox(
|
||||
height: imageSize,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: curatedLocation.value?.length,
|
||||
itemBuilder: ((context, index) {
|
||||
var locationInfo = curatedLocations[index];
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/thumbnail/${locationInfo.id}';
|
||||
return ThumbnailWithInfo(
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
textInfo: locationInfo.city,
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(
|
||||
SearchResultRoute(searchTerm: locationInfo.city),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
return SizedBox(
|
||||
height: imageSize,
|
||||
child: curatedLocation.when(
|
||||
loading: () => const Center(child: ImmichLoadingIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
data: (locations) => CuratedRow(
|
||||
content: locations
|
||||
.map(
|
||||
(o) => CuratedContent(
|
||||
id: o.id,
|
||||
label: o.city,
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: imageSize,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 1,
|
||||
itemBuilder: ((context, index) {
|
||||
return ThumbnailWithInfo(
|
||||
textInfo: '',
|
||||
onTap: () {},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
.toList(),
|
||||
imageSize: imageSize,
|
||||
onTap: (content, index) {
|
||||
AutoRouter.of(context).push(
|
||||
SearchResultRoute(
|
||||
searchTerm: 'm:${content.label}',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildThings() {
|
||||
return curatedObjects.when(
|
||||
loading: () => SizedBox(
|
||||
height: imageSize,
|
||||
child: const Center(child: ImmichLoadingIndicator()),
|
||||
),
|
||||
error: (err, stack) => Text('Error: $err'),
|
||||
data: (objects) {
|
||||
return objects.isNotEmpty
|
||||
? SizedBox(
|
||||
height: imageSize,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: curatedObjects.value?.length,
|
||||
itemBuilder: ((context, index) {
|
||||
var curatedObjectInfo = objects[index];
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/thumbnail/${curatedObjectInfo.id}';
|
||||
|
||||
return ThumbnailWithInfo(
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
textInfo: curatedObjectInfo.object,
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(
|
||||
SearchResultRoute(
|
||||
searchTerm: curatedObjectInfo.object
|
||||
.capitalizeFirstLetter(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
return SizedBox(
|
||||
height: imageSize,
|
||||
child: curatedObjects.when(
|
||||
loading: () => SizedBox(
|
||||
height: imageSize,
|
||||
child: const Center(child: ImmichLoadingIndicator()),
|
||||
),
|
||||
error: (err, stack) => SizedBox(
|
||||
height: imageSize,
|
||||
child: Center(child: Text('Error: $err')),
|
||||
),
|
||||
data: (objects) => CuratedRow(
|
||||
content: objects
|
||||
.map(
|
||||
(o) => CuratedContent(
|
||||
id: o.id,
|
||||
label: o.object,
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
height: imageSize,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 1,
|
||||
itemBuilder: ((context, index) {
|
||||
return ThumbnailWithInfo(
|
||||
textInfo: '',
|
||||
noImageIcon: Icons.signal_cellular_no_sim_sharp,
|
||||
onTap: () {},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
.toList(),
|
||||
imageSize: imageSize,
|
||||
onTap: (content, index) {
|
||||
AutoRouter.of(context).push(
|
||||
SearchResultRoute(
|
||||
searchTerm: 'm:${content.label}',
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,24 +129,161 @@ class SearchPage extends HookConsumerWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: const Text(
|
||||
"search_page_places",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
).tr(),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"search_page_places",
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
).tr(),
|
||||
TextButton(
|
||||
child: Text(
|
||||
'search_page_view_all_button',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
).tr(),
|
||||
onPressed: () => AutoRouter.of(context).push(
|
||||
const CuratedLocationRoute(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
buildPlaces(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: const Text(
|
||||
"search_page_things",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
padding: const EdgeInsets.only(
|
||||
top: 24.0,
|
||||
bottom: 4.0,
|
||||
left: 16.0,
|
||||
right: 16.0,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"search_page_things",
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
).tr(),
|
||||
TextButton(
|
||||
child: Text(
|
||||
'search_page_view_all_button',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14.0,
|
||||
),
|
||||
).tr(),
|
||||
onPressed: () => AutoRouter.of(context).push(
|
||||
const CuratedObjectRoute(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
buildThings(),
|
||||
const SizedBox(height: 24.0),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'search_page_your_activity',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
).tr(),
|
||||
),
|
||||
buildThings()
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.star_outline,
|
||||
color: categoryIconColor,
|
||||
),
|
||||
title:
|
||||
Text('search_page_favorites', style: categoryTitleStyle)
|
||||
.tr(),
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
const FavoritesRoute(),
|
||||
),
|
||||
),
|
||||
const CategoryDivider(),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.schedule_outlined,
|
||||
color: categoryIconColor,
|
||||
),
|
||||
title: Text(
|
||||
'search_page_recently_added',
|
||||
style: categoryTitleStyle,
|
||||
).tr(),
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
const RecentlyAddedRoute(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24.0),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
'search_page_categories',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
).tr(),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Screenshots', style: categoryTitleStyle).tr(),
|
||||
leading: Icon(
|
||||
Icons.screenshot,
|
||||
color: categoryIconColor,
|
||||
),
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
SearchResultRoute(
|
||||
searchTerm: 'screenshots',
|
||||
),
|
||||
),
|
||||
),
|
||||
const CategoryDivider(),
|
||||
ListTile(
|
||||
title: Text('search_page_selfies', style: categoryTitleStyle)
|
||||
.tr(),
|
||||
leading: Icon(
|
||||
Icons.photo_camera_front_outlined,
|
||||
color: categoryIconColor,
|
||||
),
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
SearchResultRoute(
|
||||
searchTerm: 'selfies',
|
||||
),
|
||||
),
|
||||
),
|
||||
const CategoryDivider(),
|
||||
ListTile(
|
||||
title: Text('search_page_videos', style: categoryTitleStyle)
|
||||
.tr(),
|
||||
leading: Icon(
|
||||
Icons.play_circle_outline,
|
||||
color: categoryIconColor,
|
||||
),
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
const AllVideosRoute(),
|
||||
),
|
||||
),
|
||||
const CategoryDivider(),
|
||||
ListTile(
|
||||
title: Text(
|
||||
'search_page_motion_photos',
|
||||
style: categoryTitleStyle,
|
||||
).tr(),
|
||||
leading: Icon(
|
||||
Icons.motion_photos_on_outlined,
|
||||
color: categoryIconColor,
|
||||
),
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
const AllMotionPhotosRoute(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isSearchEnabled)
|
||||
@@ -190,3 +294,20 @@ class SearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryDivider extends StatelessWidget {
|
||||
const CategoryDivider({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 72,
|
||||
right: 16,
|
||||
),
|
||||
child: Divider(
|
||||
height: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,30 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_result_page.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_result_grid.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class SearchType {
|
||||
SearchType({required this.isClip, required this.searchTerm});
|
||||
|
||||
final bool isClip;
|
||||
final String searchTerm;
|
||||
}
|
||||
|
||||
SearchType _getSearchType(String searchTerm) {
|
||||
if (searchTerm.startsWith('m:')) {
|
||||
return SearchType(isClip: false, searchTerm: searchTerm.substring(2));
|
||||
} else {
|
||||
return SearchType(isClip: true, searchTerm: searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchResultPage extends HookConsumerWidget {
|
||||
const SearchResultPage({Key? key, required this.searchTerm})
|
||||
: super(key: key);
|
||||
const SearchResultPage({
|
||||
Key? key,
|
||||
required this.searchTerm,
|
||||
}) : super(key: key);
|
||||
|
||||
final String searchTerm;
|
||||
|
||||
@@ -22,6 +38,8 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
final searchTermController = useTextEditingController(text: "");
|
||||
final isNewSearch = useState(false);
|
||||
final currentSearchTerm = useState(searchTerm);
|
||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
final isDisplayDateGroup = useState(true);
|
||||
|
||||
FocusNode? searchFocusNode;
|
||||
|
||||
@@ -29,9 +47,16 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
() {
|
||||
searchFocusNode = FocusNode();
|
||||
|
||||
var searchType = _getSearchType(searchTerm);
|
||||
searchType.isClip
|
||||
? isDisplayDateGroup.value = false
|
||||
: isDisplayDateGroup.value = true;
|
||||
|
||||
Future.delayed(
|
||||
Duration.zero,
|
||||
() => ref.read(searchResultPageProvider.notifier).search(searchTerm),
|
||||
() => ref
|
||||
.read(searchResultPageProvider.notifier)
|
||||
.search(searchType.searchTerm, clipEnable: searchType.isClip),
|
||||
);
|
||||
return () => searchFocusNode?.dispose();
|
||||
},
|
||||
@@ -43,7 +68,15 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
searchFocusNode?.unfocus();
|
||||
isNewSearch.value = false;
|
||||
currentSearchTerm.value = newSearchTerm;
|
||||
ref.watch(searchResultPageProvider.notifier).search(newSearchTerm);
|
||||
|
||||
var searchType = _getSearchType(newSearchTerm);
|
||||
searchType.isClip
|
||||
? isDisplayDateGroup.value = false
|
||||
: isDisplayDateGroup.value = true;
|
||||
|
||||
ref
|
||||
.watch(searchResultPageProvider.notifier)
|
||||
.search(searchType.searchTerm, clipEnable: searchType.isClip);
|
||||
}
|
||||
|
||||
buildTextField() {
|
||||
@@ -76,6 +109,12 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
focusedBorder: const UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.transparent),
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16.0,
|
||||
color:
|
||||
isDarkTheme ? Colors.grey[500] : Colors.black.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -110,16 +149,13 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
|
||||
buildSearchResult() {
|
||||
var searchResultPageState = ref.watch(searchResultPageProvider);
|
||||
var searchResultRenderList = ref.watch(searchRenderListProvider);
|
||||
var allSearchAssets = ref.watch(searchResultPageProvider).searchResult;
|
||||
|
||||
var settings = ref.watch(appSettingsServiceProvider);
|
||||
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
|
||||
final showStorageIndicator =
|
||||
settings.getSetting(AppSettingsEnum.storageIndicator);
|
||||
|
||||
if (searchResultPageState.isError) {
|
||||
return const Text("Error");
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: const Text("common_server_error").tr(),
|
||||
);
|
||||
}
|
||||
|
||||
if (searchResultPageState.isLoading) {
|
||||
@@ -127,22 +163,15 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
if (searchResultPageState.isSuccess) {
|
||||
return searchResultRenderList.when(
|
||||
data: (result) {
|
||||
return ImmichAssetGrid(
|
||||
allAssets: allSearchAssets,
|
||||
renderList: result,
|
||||
assetsPerRow: assetsPerRow,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
);
|
||||
},
|
||||
error: (err, stack) {
|
||||
return Text("$err");
|
||||
},
|
||||
loading: () {
|
||||
return const CircularProgressIndicator();
|
||||
},
|
||||
);
|
||||
if (isDisplayDateGroup.value) {
|
||||
return ImmichAssetGrid(
|
||||
assets: allSearchAssets,
|
||||
);
|
||||
} else {
|
||||
return SearchResultGrid(
|
||||
assets: allSearchAssets,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
|
||||
@@ -1,58 +1,63 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
|
||||
enum AppSettingsEnum<T> {
|
||||
loadPreview<bool>("loadPreview", true),
|
||||
loadOriginal<bool>("loadOriginal", false),
|
||||
themeMode<String>("themeMode", "system"), // "light","dark","system"
|
||||
tilesPerRow<int>("tilesPerRow", 4),
|
||||
dynamicLayout<bool>("dynamicLayout", false),
|
||||
groupAssetsBy<int>("groupBy", 0),
|
||||
loadPreview<bool>(StoreKey.loadPreview, "loadPreview", true),
|
||||
loadOriginal<bool>(StoreKey.loadOriginal, "loadOriginal", false),
|
||||
themeMode<String>(
|
||||
StoreKey.themeMode,
|
||||
"themeMode",
|
||||
"system",
|
||||
), // "light","dark","system"
|
||||
tilesPerRow<int>(StoreKey.tilesPerRow, "tilesPerRow", 4),
|
||||
dynamicLayout<bool>(StoreKey.dynamicLayout, "dynamicLayout", false),
|
||||
groupAssetsBy<int>(StoreKey.groupAssetsBy, "groupBy", 0),
|
||||
uploadErrorNotificationGracePeriod<int>(
|
||||
StoreKey.uploadErrorNotificationGracePeriod,
|
||||
"uploadErrorNotificationGracePeriod",
|
||||
2,
|
||||
),
|
||||
backgroundBackupTotalProgress<bool>("backgroundBackupTotalProgress", true),
|
||||
backgroundBackupSingleProgress<bool>("backgroundBackupSingleProgress", false),
|
||||
storageIndicator<bool>("storageIndicator", true),
|
||||
thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
|
||||
imageCacheSize<int>("imageCacheSize", 350),
|
||||
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200),
|
||||
useExperimentalAssetGrid<bool>("useExperimentalAssetGrid", false);
|
||||
backgroundBackupTotalProgress<bool>(
|
||||
StoreKey.backgroundBackupTotalProgress,
|
||||
"backgroundBackupTotalProgress",
|
||||
true,
|
||||
),
|
||||
backgroundBackupSingleProgress<bool>(
|
||||
StoreKey.backgroundBackupSingleProgress,
|
||||
"backgroundBackupSingleProgress",
|
||||
false,
|
||||
),
|
||||
storageIndicator<bool>(StoreKey.storageIndicator, "storageIndicator", true),
|
||||
thumbnailCacheSize<int>(
|
||||
StoreKey.thumbnailCacheSize,
|
||||
"thumbnailCacheSize",
|
||||
10000,
|
||||
),
|
||||
imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350),
|
||||
albumThumbnailCacheSize<int>(
|
||||
StoreKey.albumThumbnailCacheSize,
|
||||
"albumThumbnailCacheSize",
|
||||
200,
|
||||
),
|
||||
selectedAlbumSortOrder<int>(
|
||||
StoreKey.selectedAlbumSortOrder,
|
||||
"selectedAlbumSortOrder",
|
||||
0,
|
||||
),
|
||||
;
|
||||
|
||||
const AppSettingsEnum(this.hiveKey, this.defaultValue);
|
||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||
|
||||
final StoreKey<T> storeKey;
|
||||
final String hiveKey;
|
||||
final T defaultValue;
|
||||
}
|
||||
|
||||
class AppSettingsService {
|
||||
late final Box hiveBox;
|
||||
|
||||
AppSettingsService() {
|
||||
hiveBox = Hive.box(userSettingInfoBox);
|
||||
T getSetting<T>(AppSettingsEnum<T> setting) {
|
||||
return Store.get(setting.storeKey, setting.defaultValue);
|
||||
}
|
||||
|
||||
T getSetting<T>(AppSettingsEnum<T> settingType) {
|
||||
if (!hiveBox.containsKey(settingType.hiveKey)) {
|
||||
return _setDefault(settingType);
|
||||
}
|
||||
|
||||
var result = hiveBox.get(settingType.hiveKey);
|
||||
|
||||
if (result is! T) {
|
||||
return _setDefault(settingType);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
setSetting<T>(AppSettingsEnum<T> settingType, T value) {
|
||||
hiveBox.put(settingType.hiveKey, value);
|
||||
}
|
||||
|
||||
T _setDefault<T>(AppSettingsEnum<T> settingType) {
|
||||
hiveBox.put(settingType.hiveKey, settingType.defaultValue);
|
||||
return settingType.defaultValue;
|
||||
void setSetting<T>(AppSettingsEnum<T> setting, T value) {
|
||||
Store.put(setting.storeKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AuthGuard extends AutoRouteGuard {
|
||||
final ApiService _apiService;
|
||||
@@ -11,21 +12,21 @@ class AuthGuard extends AutoRouteGuard {
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) async {
|
||||
try {
|
||||
// temporary fix for race condition that the _apiService
|
||||
// get called before accessToken is set
|
||||
var userInfoHiveBox = await Hive.openBox(userInfoBox);
|
||||
var accessToken = userInfoHiveBox.get(accessTokenKey);
|
||||
_apiService.setAccessToken(accessToken);
|
||||
var res = await _apiService.authenticationApi.validateAccessToken();
|
||||
|
||||
if (res != null && res.authStatus) {
|
||||
resolver.next(true);
|
||||
} else {
|
||||
router.replaceAll([const LoginRoute()]);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error [onNavigation] ${e.toString()}");
|
||||
router.replaceAll([const LoginRoute()]);
|
||||
} on ApiException catch (e) {
|
||||
if (e.code == HttpStatus.badRequest &&
|
||||
e.innerException is SocketException) {
|
||||
// offline?
|
||||
resolver.next(true);
|
||||
} else {
|
||||
debugPrint("Error [onNavigation] ${e.toString()}");
|
||||
router.replaceAll([const LoginRoute()]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ 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/onboarding/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/all_videos_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/curated_location_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/curated_object_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/recently_added_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/search_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
|
||||
import 'package:immich_mobile/modules/settings/views/settings_page.dart';
|
||||
@@ -64,8 +69,13 @@ part 'router.gr.dart';
|
||||
AutoRoute(page: VideoViewerPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: BackupControllerPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: SearchResultPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: CuratedLocationPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: CuratedObjectPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: CreateAlbumPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: FavoritesPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: AllVideosPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: AllMotionPhotosPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: RecentlyAddedPage, guards: [AuthGuard, DuplicateGuard],),
|
||||
CustomRoute<AssetSelectionPageResult?>(
|
||||
page: AssetSelectionPage,
|
||||
guards: [AuthGuard, DuplicateGuard],
|
||||
|
||||
@@ -102,6 +102,18 @@ class _$AppRouter extends RootStackRouter {
|
||||
),
|
||||
);
|
||||
},
|
||||
CuratedLocationRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const CuratedLocationPage(),
|
||||
);
|
||||
},
|
||||
CuratedObjectRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const CuratedObjectPage(),
|
||||
);
|
||||
},
|
||||
CreateAlbumRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<CreateAlbumRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
@@ -119,6 +131,24 @@ class _$AppRouter extends RootStackRouter {
|
||||
child: const FavoritesPage(),
|
||||
);
|
||||
},
|
||||
AllVideosRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const AllVideosPage(),
|
||||
);
|
||||
},
|
||||
AllMotionPhotosRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const AllMotionPhotosPage(),
|
||||
);
|
||||
},
|
||||
RecentlyAddedRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const RecentlyAddedPage(),
|
||||
);
|
||||
},
|
||||
AssetSelectionRoute.name: (routeData) {
|
||||
return CustomPage<AssetSelectionPageResult?>(
|
||||
routeData: routeData,
|
||||
@@ -331,6 +361,22 @@ class _$AppRouter extends RootStackRouter {
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
CuratedLocationRoute.name,
|
||||
path: '/curated-location-page',
|
||||
guards: [
|
||||
authGuard,
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
CuratedObjectRoute.name,
|
||||
path: '/curated-object-page',
|
||||
guards: [
|
||||
authGuard,
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
CreateAlbumRoute.name,
|
||||
path: '/create-album-page',
|
||||
@@ -347,6 +393,30 @@ class _$AppRouter extends RootStackRouter {
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
AllVideosRoute.name,
|
||||
path: '/all-videos-page',
|
||||
guards: [
|
||||
authGuard,
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
AllMotionPhotosRoute.name,
|
||||
path: '/all-motion-photos-page',
|
||||
guards: [
|
||||
authGuard,
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
RecentlyAddedRoute.name,
|
||||
path: '/recently-added-page',
|
||||
guards: [
|
||||
authGuard,
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
AssetSelectionRoute.name,
|
||||
path: '/asset-selection-page',
|
||||
@@ -618,6 +688,30 @@ class SearchResultRouteArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [CuratedLocationPage]
|
||||
class CuratedLocationRoute extends PageRouteInfo<void> {
|
||||
const CuratedLocationRoute()
|
||||
: super(
|
||||
CuratedLocationRoute.name,
|
||||
path: '/curated-location-page',
|
||||
);
|
||||
|
||||
static const String name = 'CuratedLocationRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [CuratedObjectPage]
|
||||
class CuratedObjectRoute extends PageRouteInfo<void> {
|
||||
const CuratedObjectRoute()
|
||||
: super(
|
||||
CuratedObjectRoute.name,
|
||||
path: '/curated-object-page',
|
||||
);
|
||||
|
||||
static const String name = 'CuratedObjectRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [CreateAlbumPage]
|
||||
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
|
||||
@@ -669,6 +763,42 @@ class FavoritesRoute extends PageRouteInfo<void> {
|
||||
static const String name = 'FavoritesRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AllVideosPage]
|
||||
class AllVideosRoute extends PageRouteInfo<void> {
|
||||
const AllVideosRoute()
|
||||
: super(
|
||||
AllVideosRoute.name,
|
||||
path: '/all-videos-page',
|
||||
);
|
||||
|
||||
static const String name = 'AllVideosRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AllMotionPhotosPage]
|
||||
class AllMotionPhotosRoute extends PageRouteInfo<void> {
|
||||
const AllMotionPhotosRoute()
|
||||
: super(
|
||||
AllMotionPhotosRoute.name,
|
||||
path: '/all-motion-photos-page',
|
||||
);
|
||||
|
||||
static const String name = 'AllMotionPhotosRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [RecentlyAddedPage]
|
||||
class RecentlyAddedRoute extends PageRouteInfo<void> {
|
||||
const RecentlyAddedRoute()
|
||||
: super(
|
||||
RecentlyAddedRoute.name,
|
||||
path: '/recently-added-page',
|
||||
);
|
||||
|
||||
static const String name = 'RecentlyAddedRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AssetSelectionPage]
|
||||
class AssetSelectionRoute extends PageRouteInfo<void> {
|
||||
@@ -698,7 +828,7 @@ class SelectUserForSharingRoute extends PageRouteInfo<void> {
|
||||
class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
|
||||
AlbumViewerRoute({
|
||||
Key? key,
|
||||
required String albumId,
|
||||
required int albumId,
|
||||
}) : super(
|
||||
AlbumViewerRoute.name,
|
||||
path: '/album-viewer-page',
|
||||
@@ -719,7 +849,7 @@ class AlbumViewerRouteArgs {
|
||||
|
||||
final Key? key;
|
||||
|
||||
final String albumId;
|
||||
final int albumId;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -1,132 +1,181 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
part 'album.g.dart';
|
||||
|
||||
@Collection(inheritance: false)
|
||||
class Album {
|
||||
Album.remote(AlbumResponseDto dto)
|
||||
: remoteId = dto.id,
|
||||
name = dto.albumName,
|
||||
createdAt = DateTime.parse(dto.createdAt),
|
||||
// TODO add modifiedAt to server
|
||||
modifiedAt = DateTime.parse(dto.createdAt),
|
||||
shared = dto.shared,
|
||||
ownerId = dto.ownerId,
|
||||
albumThumbnailAssetId = dto.albumThumbnailAssetId,
|
||||
assetCount = dto.assetCount,
|
||||
sharedUsers = dto.sharedUsers.map((e) => User.fromDto(e)).toList(),
|
||||
assets = dto.assets.map(Asset.remote).toList();
|
||||
|
||||
@protected
|
||||
Album({
|
||||
this.remoteId,
|
||||
this.localId,
|
||||
required this.name,
|
||||
required this.ownerId,
|
||||
required this.createdAt,
|
||||
required this.modifiedAt,
|
||||
required this.shared,
|
||||
required this.assetCount,
|
||||
this.albumThumbnailAssetId,
|
||||
this.sharedUsers = const [],
|
||||
this.assets = const [],
|
||||
});
|
||||
|
||||
Id id = Isar.autoIncrement;
|
||||
@Index(unique: false, replace: false, type: IndexType.hash)
|
||||
String? remoteId;
|
||||
@Index(unique: false, replace: false, type: IndexType.hash)
|
||||
String? localId;
|
||||
String name;
|
||||
String ownerId;
|
||||
DateTime createdAt;
|
||||
DateTime modifiedAt;
|
||||
bool shared;
|
||||
String? albumThumbnailAssetId;
|
||||
int assetCount;
|
||||
List<User> sharedUsers = const [];
|
||||
List<Asset> assets = const [];
|
||||
final IsarLink<User> owner = IsarLink<User>();
|
||||
final IsarLink<Asset> thumbnail = IsarLink<Asset>();
|
||||
final IsarLinks<User> sharedUsers = IsarLinks<User>();
|
||||
final IsarLinks<Asset> assets = IsarLinks<Asset>();
|
||||
|
||||
List<Asset> _sortedAssets = [];
|
||||
|
||||
@ignore
|
||||
List<Asset> get sortedAssets => _sortedAssets;
|
||||
|
||||
@ignore
|
||||
bool get isRemote => remoteId != null;
|
||||
|
||||
@ignore
|
||||
bool get isLocal => localId != null;
|
||||
|
||||
String get id => isRemote ? remoteId! : localId!;
|
||||
@ignore
|
||||
int get assetCount => assets.length;
|
||||
|
||||
@ignore
|
||||
String? get ownerId => owner.value?.id;
|
||||
|
||||
@ignore
|
||||
String? get ownerName {
|
||||
// Guard null owner
|
||||
if (owner.value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final name = <String>[];
|
||||
if (owner.value?.firstName != null) {
|
||||
name.add(owner.value!.firstName);
|
||||
}
|
||||
if (owner.value?.lastName != null) {
|
||||
name.add(owner.value!.lastName);
|
||||
}
|
||||
|
||||
return name.join(' ');
|
||||
}
|
||||
|
||||
Future<void> loadSortedAssets() async {
|
||||
_sortedAssets = await assets.filter().sortByFileCreatedAt().findAll();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
if (other is! Album) return false;
|
||||
return remoteId == other.remoteId &&
|
||||
return id == other.id &&
|
||||
remoteId == other.remoteId &&
|
||||
localId == other.localId &&
|
||||
name == other.name &&
|
||||
createdAt == other.createdAt &&
|
||||
modifiedAt == other.modifiedAt &&
|
||||
shared == other.shared &&
|
||||
ownerId == other.ownerId &&
|
||||
albumThumbnailAssetId == other.albumThumbnailAssetId;
|
||||
owner.value == other.owner.value &&
|
||||
thumbnail.value == other.thumbnail.value &&
|
||||
sharedUsers.length == other.sharedUsers.length &&
|
||||
assets.length == other.assets.length;
|
||||
}
|
||||
|
||||
@override
|
||||
@ignore
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
remoteId.hashCode ^
|
||||
localId.hashCode ^
|
||||
name.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
modifiedAt.hashCode ^
|
||||
shared.hashCode ^
|
||||
ownerId.hashCode ^
|
||||
albumThumbnailAssetId.hashCode;
|
||||
owner.value.hashCode ^
|
||||
thumbnail.value.hashCode ^
|
||||
sharedUsers.length.hashCode ^
|
||||
assets.length.hashCode;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json["remoteId"] = remoteId;
|
||||
json["localId"] = localId;
|
||||
json["name"] = name;
|
||||
json["ownerId"] = ownerId;
|
||||
json["createdAt"] = createdAt.millisecondsSinceEpoch;
|
||||
json["modifiedAt"] = modifiedAt.millisecondsSinceEpoch;
|
||||
json["shared"] = shared;
|
||||
json["albumThumbnailAssetId"] = albumThumbnailAssetId;
|
||||
json["assetCount"] = assetCount;
|
||||
json["sharedUsers"] = sharedUsers;
|
||||
json["assets"] = assets;
|
||||
return json;
|
||||
static Album local(AssetPathEntity ape) {
|
||||
final Album a = Album(
|
||||
name: ape.name,
|
||||
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
||||
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
|
||||
shared: false,
|
||||
);
|
||||
a.owner.value = Store.get(StoreKey.currentUser);
|
||||
a.localId = ape.id;
|
||||
return a;
|
||||
}
|
||||
|
||||
static Album? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
return Album(
|
||||
remoteId: json["remoteId"],
|
||||
localId: json["localId"],
|
||||
name: json["name"],
|
||||
ownerId: json["ownerId"],
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
json["createdAt"],
|
||||
isUtc: true,
|
||||
),
|
||||
modifiedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
json["modifiedAt"],
|
||||
isUtc: true,
|
||||
),
|
||||
shared: json["shared"],
|
||||
albumThumbnailAssetId: json["albumThumbnailAssetId"],
|
||||
assetCount: json["assetCount"],
|
||||
sharedUsers: _listFromJson<User>(json["sharedUsers"], User.fromJson),
|
||||
assets: _listFromJson<Asset>(json["assets"], Asset.fromJson),
|
||||
);
|
||||
static Future<Album> remote(AlbumResponseDto dto) async {
|
||||
final Isar db = Isar.getInstance()!;
|
||||
final Album a = Album(
|
||||
remoteId: dto.id,
|
||||
name: dto.albumName,
|
||||
createdAt: DateTime.parse(dto.createdAt),
|
||||
modifiedAt: DateTime.parse(dto.updatedAt),
|
||||
shared: dto.shared,
|
||||
);
|
||||
a.owner.value = await db.users.getById(dto.ownerId);
|
||||
if (dto.albumThumbnailAssetId != null) {
|
||||
a.thumbnail.value = await db.assets
|
||||
.where()
|
||||
.remoteIdEqualTo(dto.albumThumbnailAssetId)
|
||||
.findFirst();
|
||||
}
|
||||
return null;
|
||||
if (dto.sharedUsers.isNotEmpty) {
|
||||
final users = await db.users
|
||||
.getAllById(dto.sharedUsers.map((e) => e.id).toList(growable: false));
|
||||
a.sharedUsers.addAll(users.cast());
|
||||
}
|
||||
if (dto.assets.isNotEmpty) {
|
||||
final assets =
|
||||
await db.assets.getAllByRemoteId(dto.assets.map((e) => e.id));
|
||||
a.assets.addAll(assets);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => name;
|
||||
}
|
||||
|
||||
extension AssetsHelper on IsarCollection<Album> {
|
||||
Future<void> store(Album a) async {
|
||||
await put(a);
|
||||
await a.owner.save();
|
||||
await a.thumbnail.save();
|
||||
await a.sharedUsers.save();
|
||||
await a.assets.save();
|
||||
}
|
||||
}
|
||||
|
||||
List<T> _listFromJson<T>(
|
||||
dynamic json,
|
||||
T? Function(dynamic) fromJson,
|
||||
) {
|
||||
final result = <T>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final entry in json) {
|
||||
final value = fromJson(entry);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
extension AssetPathEntityHelper on AssetPathEntity {
|
||||
Future<List<Asset>> getAssets({
|
||||
int start = 0,
|
||||
int end = 0x7fffffffffffffff,
|
||||
Set<String>? excludedAssets,
|
||||
}) async {
|
||||
final assetEntities = await getAssetListRange(start: start, end: end);
|
||||
if (excludedAssets != null) {
|
||||
return assetEntities
|
||||
.where((e) => !excludedAssets.contains(e.id))
|
||||
.map(Asset.local)
|
||||
.toList();
|
||||
}
|
||||
return assetEntities.map(Asset.local).toList();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
extension AlbumResponseDtoHelper on AlbumResponseDto {
|
||||
List<Asset> getAssets() => assets.map(Asset.remote).toList();
|
||||
}
|
||||
|
||||
1391
mobile/lib/shared/models/album.g.dart
Normal file
1391
mobile/lib/shared/models/album.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,82 +1,90 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:immich_mobile/utils/builtin_extensions.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
part 'asset.g.dart';
|
||||
|
||||
/// Asset (online or local)
|
||||
@Collection(inheritance: false)
|
||||
class Asset {
|
||||
Asset.remote(AssetResponseDto remote)
|
||||
: remoteId = remote.id,
|
||||
isLocal = false,
|
||||
fileCreatedAt = DateTime.parse(remote.fileCreatedAt),
|
||||
fileModifiedAt = DateTime.parse(remote.fileModifiedAt),
|
||||
durationInSeconds = remote.duration.toDuration().inSeconds,
|
||||
updatedAt = DateTime.parse(remote.updatedAt),
|
||||
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
||||
type = remote.type.toAssetType(),
|
||||
fileName = p.basename(remote.originalPath),
|
||||
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
||||
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
||||
livePhotoVideoId = remote.livePhotoVideoId,
|
||||
deviceAssetId = remote.deviceAssetId,
|
||||
deviceId = remote.deviceId,
|
||||
ownerId = remote.ownerId,
|
||||
latitude = remote.exifInfo?.latitude?.toDouble(),
|
||||
longitude = remote.exifInfo?.longitude?.toDouble(),
|
||||
localId = remote.deviceAssetId,
|
||||
deviceId = fastHash(remote.deviceId),
|
||||
ownerId = fastHash(remote.ownerId),
|
||||
exifInfo =
|
||||
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
|
||||
isFavorite = remote.isFavorite;
|
||||
|
||||
Asset.local(AssetEntity local, String owner)
|
||||
Asset.local(AssetEntity local)
|
||||
: localId = local.id,
|
||||
latitude = local.latitude,
|
||||
longitude = local.longitude,
|
||||
isLocal = true,
|
||||
durationInSeconds = local.duration,
|
||||
type = AssetType.values[local.typeInt],
|
||||
height = local.height,
|
||||
width = local.width,
|
||||
fileName = local.title!,
|
||||
deviceAssetId = local.id,
|
||||
deviceId = Hive.box(userInfoBox).get(deviceIdKey),
|
||||
ownerId = owner,
|
||||
fileModifiedAt = local.modifiedDateTime.toUtc(),
|
||||
deviceId = Store.get(StoreKey.deviceIdHash),
|
||||
ownerId = Store.get(StoreKey.currentUser).isarId,
|
||||
fileModifiedAt = local.modifiedDateTime,
|
||||
updatedAt = local.modifiedDateTime,
|
||||
isFavorite = local.isFavorite,
|
||||
fileCreatedAt = local.createDateTime.toUtc() {
|
||||
fileCreatedAt = local.createDateTime {
|
||||
if (fileCreatedAt.year == 1970) {
|
||||
fileCreatedAt = fileModifiedAt;
|
||||
}
|
||||
if (local.latitude != null) {
|
||||
exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
|
||||
}
|
||||
}
|
||||
|
||||
Asset({
|
||||
this.localId,
|
||||
this.remoteId,
|
||||
required this.deviceAssetId,
|
||||
required this.localId,
|
||||
required this.deviceId,
|
||||
required this.ownerId,
|
||||
required this.fileCreatedAt,
|
||||
required this.fileModifiedAt,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.updatedAt,
|
||||
required this.durationInSeconds,
|
||||
required this.type,
|
||||
this.width,
|
||||
this.height,
|
||||
required this.fileName,
|
||||
this.livePhotoVideoId,
|
||||
this.exifInfo,
|
||||
required this.isFavorite,
|
||||
required this.isLocal,
|
||||
});
|
||||
|
||||
@ignore
|
||||
AssetEntity? _local;
|
||||
|
||||
@ignore
|
||||
AssetEntity? get local {
|
||||
if (isLocal && _local == null) {
|
||||
_local = AssetEntity(
|
||||
id: localId!.toString(),
|
||||
id: localId,
|
||||
typeInt: isImage ? 1 : 2,
|
||||
width: width!,
|
||||
height: height!,
|
||||
width: width ?? 0,
|
||||
height: height ?? 0,
|
||||
duration: durationInSeconds,
|
||||
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
|
||||
title: fileName,
|
||||
);
|
||||
@@ -84,110 +92,209 @@ class Asset {
|
||||
return _local;
|
||||
}
|
||||
|
||||
String? localId;
|
||||
Id id = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: false, replace: false, type: IndexType.hash)
|
||||
String? remoteId;
|
||||
|
||||
String deviceAssetId;
|
||||
@Index(
|
||||
unique: false,
|
||||
replace: false,
|
||||
type: IndexType.hash,
|
||||
composite: [CompositeIndex('deviceId')],
|
||||
)
|
||||
String localId;
|
||||
|
||||
String deviceId;
|
||||
int deviceId;
|
||||
|
||||
String ownerId;
|
||||
int ownerId;
|
||||
|
||||
DateTime fileCreatedAt;
|
||||
|
||||
DateTime fileModifiedAt;
|
||||
|
||||
double? latitude;
|
||||
|
||||
double? longitude;
|
||||
DateTime updatedAt;
|
||||
|
||||
int durationInSeconds;
|
||||
|
||||
int? width;
|
||||
@Enumerated(EnumType.ordinal)
|
||||
AssetType type;
|
||||
|
||||
int? height;
|
||||
short? width;
|
||||
|
||||
short? height;
|
||||
|
||||
String fileName;
|
||||
|
||||
String? livePhotoVideoId;
|
||||
|
||||
ExifInfo? exifInfo;
|
||||
|
||||
bool isFavorite;
|
||||
|
||||
String get id => isLocal ? localId.toString() : remoteId!;
|
||||
bool isLocal;
|
||||
|
||||
@ignore
|
||||
ExifInfo? exifInfo;
|
||||
|
||||
@ignore
|
||||
bool get isInDb => id != Isar.autoIncrement;
|
||||
|
||||
@ignore
|
||||
String get name => p.withoutExtension(fileName);
|
||||
|
||||
@ignore
|
||||
bool get isRemote => remoteId != null;
|
||||
|
||||
bool get isLocal => localId != null;
|
||||
|
||||
bool get isImage => durationInSeconds == 0;
|
||||
@ignore
|
||||
bool get isImage => type == AssetType.image;
|
||||
|
||||
@ignore
|
||||
Duration get duration => Duration(seconds: durationInSeconds);
|
||||
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
if (other is! Asset) return false;
|
||||
return id == other.id && isLocal == other.isLocal;
|
||||
return id == other.id &&
|
||||
remoteId == other.remoteId &&
|
||||
localId == other.localId &&
|
||||
deviceId == other.deviceId &&
|
||||
ownerId == other.ownerId &&
|
||||
fileCreatedAt == other.fileCreatedAt &&
|
||||
fileModifiedAt == other.fileModifiedAt &&
|
||||
updatedAt == other.updatedAt &&
|
||||
durationInSeconds == other.durationInSeconds &&
|
||||
type == other.type &&
|
||||
width == other.width &&
|
||||
height == other.height &&
|
||||
fileName == other.fileName &&
|
||||
livePhotoVideoId == other.livePhotoVideoId &&
|
||||
isFavorite == other.isFavorite &&
|
||||
isLocal == other.isLocal;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
@ignore
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
remoteId.hashCode ^
|
||||
localId.hashCode ^
|
||||
deviceId.hashCode ^
|
||||
ownerId.hashCode ^
|
||||
fileCreatedAt.hashCode ^
|
||||
fileModifiedAt.hashCode ^
|
||||
updatedAt.hashCode ^
|
||||
durationInSeconds.hashCode ^
|
||||
type.hashCode ^
|
||||
width.hashCode ^
|
||||
height.hashCode ^
|
||||
fileName.hashCode ^
|
||||
livePhotoVideoId.hashCode ^
|
||||
isFavorite.hashCode ^
|
||||
isLocal.hashCode;
|
||||
|
||||
// methods below are only required for caching as JSON
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json["localId"] = localId;
|
||||
json["remoteId"] = remoteId;
|
||||
json["deviceAssetId"] = deviceAssetId;
|
||||
json["deviceId"] = deviceId;
|
||||
json["ownerId"] = ownerId;
|
||||
json["fileCreatedAt"] = fileCreatedAt.millisecondsSinceEpoch;
|
||||
json["fileModifiedAt"] = fileModifiedAt.millisecondsSinceEpoch;
|
||||
json["latitude"] = latitude;
|
||||
json["longitude"] = longitude;
|
||||
json["durationInSeconds"] = durationInSeconds;
|
||||
json["width"] = width;
|
||||
json["height"] = height;
|
||||
json["fileName"] = fileName;
|
||||
json["livePhotoVideoId"] = livePhotoVideoId;
|
||||
json["isFavorite"] = isFavorite;
|
||||
if (exifInfo != null) {
|
||||
json["exifInfo"] = exifInfo!.toJson();
|
||||
bool updateFromAssetEntity(AssetEntity ae) {
|
||||
// TODO check more fields;
|
||||
// width and height are most important because local assets require these
|
||||
final bool hasChanges =
|
||||
isLocal == false || width != ae.width || height != ae.height;
|
||||
if (hasChanges) {
|
||||
isLocal = true;
|
||||
width = ae.width;
|
||||
height = ae.height;
|
||||
}
|
||||
return json;
|
||||
return hasChanges;
|
||||
}
|
||||
|
||||
static Asset? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
return Asset(
|
||||
localId: json["localId"],
|
||||
remoteId: json["remoteId"],
|
||||
deviceAssetId: json["deviceAssetId"],
|
||||
deviceId: json["deviceId"],
|
||||
ownerId: json["ownerId"],
|
||||
fileCreatedAt:
|
||||
DateTime.fromMillisecondsSinceEpoch(json["fileCreatedAt"], isUtc: true),
|
||||
fileModifiedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
json["fileModifiedAt"],
|
||||
isUtc: true,
|
||||
),
|
||||
latitude: json["latitude"],
|
||||
longitude: json["longitude"],
|
||||
durationInSeconds: json["durationInSeconds"],
|
||||
width: json["width"],
|
||||
height: json["height"],
|
||||
fileName: json["fileName"],
|
||||
livePhotoVideoId: json["livePhotoVideoId"],
|
||||
exifInfo: ExifInfo.fromJson(json["exifInfo"]),
|
||||
isFavorite: json["isFavorite"],
|
||||
);
|
||||
Asset withUpdatesFromDto(AssetResponseDto dto) =>
|
||||
Asset.remote(dto).updateFromDb(this);
|
||||
|
||||
Asset updateFromDb(Asset a) {
|
||||
assert(localId == a.localId);
|
||||
assert(deviceId == a.deviceId);
|
||||
id = a.id;
|
||||
isLocal |= a.isLocal;
|
||||
remoteId ??= a.remoteId;
|
||||
width ??= a.width;
|
||||
height ??= a.height;
|
||||
exifInfo ??= a.exifInfo;
|
||||
exifInfo?.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
Future<void> put(Isar db) async {
|
||||
await db.assets.put(this);
|
||||
if (exifInfo != null) {
|
||||
exifInfo!.id = id;
|
||||
await db.exifInfos.put(exifInfo!);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// compares assets by [ownerId], [deviceId], [localId]
|
||||
static int compareByOwnerDeviceLocalId(Asset a, Asset b) {
|
||||
final int ownerIdOrder = a.ownerId.compareTo(b.ownerId);
|
||||
if (ownerIdOrder != 0) {
|
||||
return ownerIdOrder;
|
||||
}
|
||||
final int deviceIdOrder = a.deviceId.compareTo(b.deviceId);
|
||||
if (deviceIdOrder != 0) {
|
||||
return deviceIdOrder;
|
||||
}
|
||||
final int localIdOrder = a.localId.compareTo(b.localId);
|
||||
return localIdOrder;
|
||||
}
|
||||
|
||||
/// compares assets by [ownerId], [deviceId], [localId], [fileModifiedAt]
|
||||
static int compareByOwnerDeviceLocalIdModified(Asset a, Asset b) {
|
||||
final int order = compareByOwnerDeviceLocalId(a, b);
|
||||
return order != 0 ? order : a.fileModifiedAt.compareTo(b.fileModifiedAt);
|
||||
}
|
||||
|
||||
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
|
||||
|
||||
static int compareByLocalId(Asset a, Asset b) =>
|
||||
a.localId.compareTo(b.localId);
|
||||
}
|
||||
|
||||
enum AssetType {
|
||||
// do not change this order!
|
||||
other,
|
||||
image,
|
||||
video,
|
||||
audio,
|
||||
}
|
||||
|
||||
extension AssetTypeEnumHelper on AssetTypeEnum {
|
||||
AssetType toAssetType() {
|
||||
switch (this) {
|
||||
case AssetTypeEnum.IMAGE:
|
||||
return AssetType.image;
|
||||
case AssetTypeEnum.VIDEO:
|
||||
return AssetType.video;
|
||||
case AssetTypeEnum.AUDIO:
|
||||
return AssetType.audio;
|
||||
case AssetTypeEnum.OTHER:
|
||||
return AssetType.other;
|
||||
}
|
||||
throw Exception();
|
||||
}
|
||||
}
|
||||
|
||||
extension AssetsHelper on IsarCollection<Asset> {
|
||||
Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
|
||||
ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();
|
||||
Future<int> deleteAllByLocalId(Iterable<String> ids) =>
|
||||
ids.isEmpty ? Future.value(0) : _local(ids).deleteAll();
|
||||
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) =>
|
||||
ids.isEmpty ? Future.value([]) : _remote(ids).findAll();
|
||||
Future<List<Asset>> getAllByLocalId(Iterable<String> ids) =>
|
||||
ids.isEmpty ? Future.value([]) : _local(ids).findAll();
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> _remote(Iterable<String> ids) =>
|
||||
where().anyOf(ids, (q, String e) => q.remoteIdEqualTo(e));
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> _local(Iterable<String> ids) {
|
||||
return where().anyOf(
|
||||
ids,
|
||||
(q, String e) =>
|
||||
q.localIdDeviceIdEqualTo(e, Store.get(StoreKey.deviceIdHash)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
2270
mobile/lib/shared/models/asset.g.dart
Normal file
2270
mobile/lib/shared/models/asset.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,86 +1,93 @@
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/utils/builtin_extensions.dart';
|
||||
|
||||
part 'exif_info.g.dart';
|
||||
|
||||
/// Exif information 1:1 relation with Asset
|
||||
@Collection(inheritance: false)
|
||||
class ExifInfo {
|
||||
Id? id;
|
||||
int? fileSize;
|
||||
String? make;
|
||||
String? model;
|
||||
String? orientation;
|
||||
String? lensModel;
|
||||
double? fNumber;
|
||||
double? focalLength;
|
||||
int? iso;
|
||||
double? exposureTime;
|
||||
String? lens;
|
||||
float? f;
|
||||
float? mm;
|
||||
short? iso;
|
||||
float? exposureSeconds;
|
||||
float? lat;
|
||||
float? long;
|
||||
String? city;
|
||||
String? state;
|
||||
String? country;
|
||||
|
||||
@ignore
|
||||
String get exposureTime {
|
||||
if (exposureSeconds == null) {
|
||||
return "";
|
||||
} else if (exposureSeconds! < 1) {
|
||||
return "1/${(1.0 / exposureSeconds!).round()} s";
|
||||
} else {
|
||||
return "${exposureSeconds!.toStringAsFixed(1)} s";
|
||||
}
|
||||
}
|
||||
|
||||
@ignore
|
||||
String get fNumber => f != null ? f!.toStringAsFixed(1) : "";
|
||||
|
||||
@ignore
|
||||
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
|
||||
|
||||
@ignore
|
||||
double? get latitude => lat;
|
||||
|
||||
@ignore
|
||||
double? get longitude => long;
|
||||
|
||||
ExifInfo.fromDto(ExifResponseDto dto)
|
||||
: fileSize = dto.fileSizeInByte,
|
||||
make = dto.make,
|
||||
model = dto.model,
|
||||
orientation = dto.orientation,
|
||||
lensModel = dto.lensModel,
|
||||
fNumber = dto.fNumber?.toDouble(),
|
||||
focalLength = dto.focalLength?.toDouble(),
|
||||
lens = dto.lensModel,
|
||||
f = dto.fNumber?.toDouble(),
|
||||
mm = dto.focalLength?.toDouble(),
|
||||
iso = dto.iso?.toInt(),
|
||||
exposureTime = dto.exposureTime?.toDouble(),
|
||||
exposureSeconds = _exposureTimeToSeconds(dto.exposureTime),
|
||||
lat = dto.latitude?.toDouble(),
|
||||
long = dto.longitude?.toDouble(),
|
||||
city = dto.city,
|
||||
state = dto.state,
|
||||
country = dto.country;
|
||||
|
||||
// stuff below is only required for caching as JSON
|
||||
|
||||
ExifInfo(
|
||||
ExifInfo({
|
||||
this.fileSize,
|
||||
this.make,
|
||||
this.model,
|
||||
this.orientation,
|
||||
this.lensModel,
|
||||
this.fNumber,
|
||||
this.focalLength,
|
||||
this.lens,
|
||||
this.f,
|
||||
this.mm,
|
||||
this.iso,
|
||||
this.exposureTime,
|
||||
this.exposureSeconds,
|
||||
this.lat,
|
||||
this.long,
|
||||
this.city,
|
||||
this.state,
|
||||
this.country,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json["fileSize"] = fileSize;
|
||||
json["make"] = make;
|
||||
json["model"] = model;
|
||||
json["orientation"] = orientation;
|
||||
json["lensModel"] = lensModel;
|
||||
json["fNumber"] = fNumber;
|
||||
json["focalLength"] = focalLength;
|
||||
json["iso"] = iso;
|
||||
json["exposureTime"] = exposureTime;
|
||||
json["city"] = city;
|
||||
json["state"] = state;
|
||||
json["country"] = country;
|
||||
return json;
|
||||
}
|
||||
|
||||
static ExifInfo? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
return ExifInfo(
|
||||
json["fileSize"],
|
||||
json["make"],
|
||||
json["model"],
|
||||
json["orientation"],
|
||||
json["lensModel"],
|
||||
json["fNumber"],
|
||||
json["focalLength"],
|
||||
json["iso"],
|
||||
json["exposureTime"],
|
||||
json["city"],
|
||||
json["state"],
|
||||
json["country"],
|
||||
);
|
||||
}
|
||||
double? _exposureTimeToSeconds(String? s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
double? value = double.tryParse(s);
|
||||
if (value != null) {
|
||||
return value;
|
||||
}
|
||||
final parts = s.split("/");
|
||||
if (parts.length == 2) {
|
||||
return parts[0].toDouble() / parts[1].toDouble();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user