Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
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
|
||||
|
||||
@@ -16,6 +16,19 @@ sidebar_position: 7
|
||||
|
||||
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).
|
||||
|
||||
### 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 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.
|
||||
|
||||
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" => 74,
|
||||
"android.injected.version.name" => "1.51.2",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -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,8 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.51.2"
|
||||
)
|
||||
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,12 @@ 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/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';
|
||||
@@ -42,6 +48,7 @@ void main() async {
|
||||
await initApp();
|
||||
final db = await loadDb();
|
||||
await migrateHiveToStoreIfNecessary();
|
||||
await migrateJsonCacheIfNecessary();
|
||||
runApp(getMainWidget(db));
|
||||
}
|
||||
|
||||
@@ -93,7 +100,15 @@ 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,
|
||||
],
|
||||
directory: dir.path,
|
||||
maxSizeMiB: 256,
|
||||
);
|
||||
@@ -145,10 +160,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();
|
||||
|
||||
@@ -200,6 +217,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<User>(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.userRemoteId)) {
|
||||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -52,6 +52,8 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
),
|
||||
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
|
||||
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.userRemoteId);
|
||||
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();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
],
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 +39,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;
|
||||
|
||||
@@ -59,6 +59,15 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
late Offset localPosition;
|
||||
final authToken = 'Bearer ${box.get(accessTokenKey)}';
|
||||
|
||||
showAppBar.addListener(() {
|
||||
// Change to and from immersive mode, hiding navigation and app bar
|
||||
if (showAppBar.value) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} else {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
isLoadPreview.value =
|
||||
@@ -75,15 +84,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 +132,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 +156,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
if (isLoadPreview.value) {
|
||||
// Precache the JPEG thumbnail
|
||||
@@ -153,6 +166,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
if (isLoadOriginal.value) {
|
||||
@@ -160,6 +174,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
precacheImage(
|
||||
originalImageProvider(asset),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -292,140 +307,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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,21 +4,25 @@ 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 +55,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 +194,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 +212,6 @@ class BackgroundService {
|
||||
}
|
||||
_hasLock = true;
|
||||
rp.listen(_heartbeatListener);
|
||||
_hasAccessCompleter.complete(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -267,8 +261,6 @@ class BackgroundService {
|
||||
void releaseLock() {
|
||||
_wantsLockTime = 0;
|
||||
if (_hasLock) {
|
||||
_hasAccessCompleter = Completer();
|
||||
_hasAccess = _hasAccessCompleter.future;
|
||||
IsolateNameServer.removePortNameMapping(_portNameLock);
|
||||
_waitingIsolate?.send(true);
|
||||
_waitingIsolate = null;
|
||||
@@ -339,29 +331,24 @@ class BackgroundService {
|
||||
}
|
||||
|
||||
Future<bool> _onAssetsChanged() async {
|
||||
final Isar db = await loadDb();
|
||||
await Hive.initFlutter();
|
||||
|
||||
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);
|
||||
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 +358,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.get(StoreKey.backupFailedSince) == null) {
|
||||
Store.put(StoreKey.backupFailedSince, DateTime.now());
|
||||
return false;
|
||||
}
|
||||
// Android should check for new assets added while performing backup
|
||||
@@ -395,7 +401,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 +414,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 +529,7 @@ class BackgroundService {
|
||||
} else if (value == 5) {
|
||||
return false;
|
||||
}
|
||||
final DateTime? failedSince =
|
||||
Hive.box(backgroundBackupInfoBox).get(backupFailedSince);
|
||||
final DateTime? failedSince = Store.get(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');
|
||||
});
|
||||
}
|
||||
}
|
||||
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,26 @@
|
||||
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 +33,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
this._authState,
|
||||
this._backgroundService,
|
||||
this._galleryPermissionNotifier,
|
||||
this._db,
|
||||
this.ref,
|
||||
) : super(
|
||||
BackUpState(
|
||||
@@ -69,6 +74,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
final AuthenticationState _authState;
|
||||
final BackgroundService _backgroundService;
|
||||
final GalleryPermissionNotifier _galleryPermissionNotifier;
|
||||
final Isar _db;
|
||||
final Ref ref;
|
||||
|
||||
///
|
||||
@@ -157,11 +163,13 @@ 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),
|
||||
Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi),
|
||||
Store.put(
|
||||
StoreKey.backupRequireCharging,
|
||||
state.backupRequireCharging,
|
||||
),
|
||||
Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay),
|
||||
]);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
@@ -201,16 +209,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 +237,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 +258,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 +300,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 +358,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
// Save to persistent storage
|
||||
_updatePersistentAlbumsSelection();
|
||||
await _updatePersistentAlbumsSelection();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -395,7 +367,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 +378,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 +432,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 +532,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 +544,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
Future<void> _resumeBackup() async {
|
||||
// Check if user is login
|
||||
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
||||
final accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
||||
|
||||
// User has been logged out return
|
||||
if (accessKey == null || !_authState.isAuthenticated) {
|
||||
@@ -590,65 +575,56 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
}
|
||||
|
||||
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),
|
||||
backupRequireWifi: Store.get(StoreKey.backupRequireWifi),
|
||||
backupRequireCharging: Store.get(StoreKey.backupRequireCharging),
|
||||
backupTriggerDelay: Store.get(StoreKey.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 +643,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 +656,7 @@ final backupProvider =
|
||||
ref.watch(authenticationProvider),
|
||||
ref.watch(backgroundServiceProvider),
|
||||
ref.watch(galleryPermissionNotifier.notifier),
|
||||
ref.watch(dbProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,31 +8,34 @@ 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/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);
|
||||
@@ -45,32 +48,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 +80,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 +138,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 +162,7 @@ class BackupService {
|
||||
if (candidates.isEmpty) {
|
||||
return candidates;
|
||||
}
|
||||
final Set<String> duplicatedAssetIds = getDuplicatedAssetIds();
|
||||
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
|
||||
candidates = duplicatedAssetIds.isEmpty
|
||||
? candidates
|
||||
: candidates
|
||||
@@ -261,7 +250,8 @@ class BackupService {
|
||||
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 +322,7 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
if (duplicatedAssetIds.isNotEmpty) {
|
||||
_saveDuplicatedAssetIdToLocalStorage(duplicatedAssetIds);
|
||||
await _saveDuplicatedAssetIds(duplicatedAssetIds);
|
||||
}
|
||||
return !anyErrors;
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
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 -
|
||||
@@ -292,15 +292,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 +312,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 +334,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 +372,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 +394,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 +413,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 +526,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 +588,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 +626,6 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
).tr(),
|
||||
),
|
||||
buildBackgroundBackupInfo(),
|
||||
buildFolderSelectionTile(),
|
||||
BackupInfoCard(
|
||||
title: "backup_controller_page_total".tr(),
|
||||
@@ -681,22 +654,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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
|
||||
ItemPositionsListener.create();
|
||||
|
||||
bool _scrolling = false;
|
||||
final Set<String> _selectedAssets = HashSet();
|
||||
final Set<int> _selectedAssets = HashSet();
|
||||
|
||||
Set<Asset> _getSelectedAssets() {
|
||||
return _selectedAssets
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -66,6 +66,8 @@ class HomePageAppBar extends ConsumerWidget with PreferredSizeWidget {
|
||||
image:
|
||||
'$endpoint/user/profile-image/${authState.userId}?d=${dummy++}',
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageErrorBuilder: (context, error, stackTrace) =>
|
||||
Image.memory(kTransparentImage),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -40,6 +40,8 @@ class ProfileDrawerHeader extends HookConsumerWidget {
|
||||
image:
|
||||
'$endpoint/user/profile-image/${authState.userId}?d=${dummy++}',
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageErrorBuilder: (context, error, stackTrace) =>
|
||||
Image.memory(kTransparentImage),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -38,7 +38,7 @@ 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);
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
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> {
|
||||
@@ -19,9 +21,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
this._deviceInfoService,
|
||||
this._backupService,
|
||||
this._apiService,
|
||||
this._assetCacheService,
|
||||
this._albumCacheService,
|
||||
this._sharedAlbumCacheService,
|
||||
) : super(
|
||||
AuthenticationState(
|
||||
deviceId: "",
|
||||
@@ -48,9 +47,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
final DeviceInfoService _deviceInfoService;
|
||||
final BackupService _backupService;
|
||||
final ApiService _apiService;
|
||||
final AssetCacheService _assetCacheService;
|
||||
final AlbumCacheService _albumCacheService;
|
||||
final SharedAlbumCacheService _sharedAlbumCacheService;
|
||||
|
||||
Future<bool> login(
|
||||
String email,
|
||||
@@ -98,9 +94,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
Hive.box(userInfoBox).delete(accessTokenKey),
|
||||
Store.delete(StoreKey.assetETag),
|
||||
Store.delete(StoreKey.userRemoteId),
|
||||
_assetCacheService.invalidate(),
|
||||
_albumCacheService.invalidate(),
|
||||
_sharedAlbumCacheService.invalidate(),
|
||||
Store.delete(StoreKey.currentUser),
|
||||
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey)
|
||||
]);
|
||||
|
||||
@@ -153,14 +147,24 @@ 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.deviceId, deviceInfo["deviceId"]);
|
||||
Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"]));
|
||||
Store.put(StoreKey.userRemoteId, userResponseDto.id);
|
||||
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
|
||||
|
||||
state = state.copyWith(
|
||||
isAuthenticated: true,
|
||||
@@ -205,7 +209,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
state = state.copyWith(deviceInfo: deviceInfo);
|
||||
} catch (e) {
|
||||
debugPrint("ERROR Register Device Info: $e");
|
||||
return false;
|
||||
return e is ApiException && e.innerException is SocketException;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -218,8 +222,5 @@ final authenticationProvider =
|
||||
ref.watch(deviceInfoServiceProvider),
|
||||
ref.watch(backupServiceProvider),
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(assetCacheServiceProvider),
|
||||
ref.watch(albumCacheServiceProvider),
|
||||
ref.watch(sharedAlbumCacheServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -32,48 +32,78 @@ 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);
|
||||
|
||||
@@ -83,7 +113,6 @@ class LoginForm extends HookConsumerWidget {
|
||||
serverEndpointController.text = loginInfo.serverUrl;
|
||||
}
|
||||
|
||||
getServeLoginConfig();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
@@ -95,215 +124,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);
|
||||
|
||||
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 =
|
||||
await ref.read(authenticationProvider.notifier).login(
|
||||
emailController.text,
|
||||
passwordController.text,
|
||||
serverEndpointController.text,
|
||||
);
|
||||
// This will remove current cache asset state of previous user login.
|
||||
ref.read(assetProvider.notifier).clearAllAsset();
|
||||
|
||||
try {
|
||||
final isAuthenticated =
|
||||
await ref.read(authenticationProvider.notifier).login(
|
||||
usernameController.text,
|
||||
passwordController.text,
|
||||
serverEndpointController.text.trim(),
|
||||
);
|
||||
if (isAuthenticated) {
|
||||
// Resume backup (if enable) then navigate
|
||||
if (ref.read(authenticationProvider).shouldChangePassword &&
|
||||
@@ -326,35 +160,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 +200,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 +228,330 @@ 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(),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
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,
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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 {
|
||||
@@ -26,13 +30,15 @@ class SearchService {
|
||||
}
|
||||
|
||||
Future<List<Asset>?> searchAsset(String searchTerm) 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));
|
||||
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.map((e) => e.id));
|
||||
} catch (e) {
|
||||
debugPrint("[ERROR] [searchAsset] ${e.toString()}");
|
||||
return null;
|
||||
|
||||
@@ -53,6 +53,8 @@ class ThumbnailWithInfo extends StatelessWidget {
|
||||
httpHeaders: {
|
||||
"Authorization": "Bearer ${box.get(accessTokenKey)}"
|
||||
},
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
|
||||
@@ -119,7 +119,10 @@ class SearchResultPage extends HookConsumerWidget {
|
||||
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) {
|
||||
|
||||
@@ -18,7 +18,8 @@ enum AppSettingsEnum<T> {
|
||||
thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
|
||||
imageCacheSize<int>("imageCacheSize", 350),
|
||||
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200),
|
||||
useExperimentalAssetGrid<bool>("useExperimentalAssetGrid", false);
|
||||
useExperimentalAssetGrid<bool>("useExperimentalAssetGrid", false),
|
||||
selectedAlbumSortOrder<int>("selectedAlbumSortOrder", 0);
|
||||
|
||||
const AppSettingsEnum(this.hiveKey, this.defaultValue);
|
||||
|
||||
|
||||
@@ -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,11 +12,6 @@ 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) {
|
||||
@@ -23,9 +19,15 @@ class AuthGuard extends AutoRouteGuard {
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,7 +698,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 +719,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,60 +1,66 @@
|
||||
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/shared/models/user.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,
|
||||
fileCreatedAt = DateTime.parse(remote.fileCreatedAt),
|
||||
fileModifiedAt = DateTime.parse(remote.fileModifiedAt),
|
||||
durationInSeconds = remote.duration.toDuration().inSeconds,
|
||||
isLocal = false,
|
||||
fileCreatedAt = DateTime.parse(remote.fileCreatedAt).toUtc(),
|
||||
fileModifiedAt = DateTime.parse(remote.fileModifiedAt).toUtc(),
|
||||
updatedAt = DateTime.parse(remote.updatedAt).toUtc(),
|
||||
// use -1 as fallback duration (to not mix it up with non-video assets correctly having duration=0)
|
||||
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? -1,
|
||||
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,
|
||||
height = local.height,
|
||||
width = local.width,
|
||||
fileName = local.title!,
|
||||
deviceAssetId = local.id,
|
||||
deviceId = Hive.box(userInfoBox).get(deviceIdKey),
|
||||
ownerId = owner,
|
||||
deviceId = Store.get(StoreKey.deviceIdHash),
|
||||
ownerId = Store.get<User>(StoreKey.currentUser)!.isarId,
|
||||
fileModifiedAt = local.modifiedDateTime.toUtc(),
|
||||
updatedAt = local.modifiedDateTime.toUtc(),
|
||||
isFavorite = local.isFavorite,
|
||||
fileCreatedAt = local.createDateTime.toUtc() {
|
||||
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,
|
||||
this.width,
|
||||
this.height,
|
||||
@@ -62,21 +68,22 @@ class Asset {
|
||||
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.toString(),
|
||||
typeInt: isImage ? 1 : 2,
|
||||
width: width!,
|
||||
height: height!,
|
||||
duration: durationInSeconds,
|
||||
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
|
||||
title: fileName,
|
||||
);
|
||||
@@ -84,110 +91,136 @@ class Asset {
|
||||
return _local;
|
||||
}
|
||||
|
||||
String? localId;
|
||||
Id id = Isar.autoIncrement;
|
||||
|
||||
@Index(unique: false, replace: false, type: IndexType.hash)
|
||||
String? remoteId;
|
||||
|
||||
String deviceAssetId;
|
||||
@Index(
|
||||
unique: true,
|
||||
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;
|
||||
short? width;
|
||||
|
||||
int? height;
|
||||
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;
|
||||
|
||||
@ignore
|
||||
bool get isImage => durationInSeconds == 0;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
@override
|
||||
@ignore
|
||||
int get hashCode => id.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;
|
||||
}
|
||||
|
||||
static int compareByDeviceIdLocalId(Asset a, Asset b) {
|
||||
final int order = a.deviceId.compareTo(b.deviceId);
|
||||
return order == 0 ? a.localId.compareTo(b.localId) : order;
|
||||
}
|
||||
|
||||
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
|
||||
|
||||
static int compareByLocalId(Asset a, Asset b) =>
|
||||
a.localId.compareTo(b.localId);
|
||||
}
|
||||
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
2244
mobile/lib/shared/models/asset.g.dart
Normal file
2244
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;
|
||||
}
|
||||
|
||||
2304
mobile/lib/shared/models/exif_info.g.dart
Normal file
2304
mobile/lib/shared/models/exif_info.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
@@ -8,7 +10,8 @@ part 'store.g.dart';
|
||||
/// Can be used concurrently from multiple isolates
|
||||
class Store {
|
||||
static late final Isar _db;
|
||||
static final List<dynamic> _cache = List.filled(StoreKey.values.length, null);
|
||||
static final List<dynamic> _cache =
|
||||
List.filled(StoreKey.values.map((e) => e.id).max + 1, null);
|
||||
|
||||
/// Initializes the store (call exactly once per app start)
|
||||
static void init(Isar db) {
|
||||
@@ -25,26 +28,28 @@ class Store {
|
||||
|
||||
/// Returns the stored value for the given key, or the default value if null
|
||||
static T? get<T>(StoreKey key, [T? defaultValue]) =>
|
||||
_cache[key._id] ?? defaultValue;
|
||||
_cache[key.id] ?? defaultValue;
|
||||
|
||||
/// Stores the value synchronously in the cache and asynchronously in the DB
|
||||
static Future<void> put<T>(StoreKey key, T value) {
|
||||
_cache[key._id] = value;
|
||||
return _db.writeTxn(() => _db.storeValues.put(StoreValue._of(value, key)));
|
||||
_cache[key.id] = value;
|
||||
return _db.writeTxn(
|
||||
() async => _db.storeValues.put(await StoreValue._of(value, key)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes the value synchronously from the cache and asynchronously from the DB
|
||||
static Future<void> delete(StoreKey key) {
|
||||
_cache[key._id] = null;
|
||||
return _db.writeTxn(() => _db.storeValues.delete(key._id));
|
||||
_cache[key.id] = null;
|
||||
return _db.writeTxn(() => _db.storeValues.delete(key.id));
|
||||
}
|
||||
|
||||
/// Fills the cache with the values from the DB
|
||||
static _populateCache() {
|
||||
for (StoreKey key in StoreKey.values) {
|
||||
final StoreValue? value = _db.storeValues.getSync(key._id);
|
||||
final StoreValue? value = _db.storeValues.getSync(key.id);
|
||||
if (value != null) {
|
||||
_cache[key._id] = value._extract(key);
|
||||
_cache[key.id] = value._extract(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,18 +72,44 @@ class StoreValue {
|
||||
int? intValue;
|
||||
String? strValue;
|
||||
|
||||
T? _extract<T>(StoreKey key) => key._isInt
|
||||
? intValue
|
||||
: (key._fromJson != null
|
||||
? key._fromJson!(json.decode(strValue!))
|
||||
: strValue);
|
||||
static StoreValue _of(dynamic value, StoreKey key) => StoreValue(
|
||||
key._id,
|
||||
intValue: key._isInt ? value : null,
|
||||
strValue: key._isInt
|
||||
dynamic _extract(StoreKey key) {
|
||||
switch (key.type) {
|
||||
case int:
|
||||
return key.fromDb == null
|
||||
? intValue
|
||||
: key.fromDb!.call(Store._db, intValue!);
|
||||
case bool:
|
||||
return intValue == null ? null : intValue! == 1;
|
||||
case DateTime:
|
||||
return intValue == null
|
||||
? null
|
||||
: (key._fromJson == null ? value : json.encode(value.toJson())),
|
||||
);
|
||||
: DateTime.fromMicrosecondsSinceEpoch(intValue!);
|
||||
case String:
|
||||
return key.fromJson != null
|
||||
? key.fromJson!.call(json.decode(strValue!))
|
||||
: strValue;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<StoreValue> _of(dynamic value, StoreKey key) async {
|
||||
int? i;
|
||||
String? s;
|
||||
switch (key.type) {
|
||||
case int:
|
||||
i = (key.toDb == null ? value : await key.toDb!.call(Store._db, value));
|
||||
break;
|
||||
case bool:
|
||||
i = value == null ? null : (value ? 1 : 0);
|
||||
break;
|
||||
case DateTime:
|
||||
i = value == null ? null : (value as DateTime).microsecondsSinceEpoch;
|
||||
break;
|
||||
case String:
|
||||
s = key.fromJson == null ? value : json.encode(value.toJson());
|
||||
break;
|
||||
}
|
||||
return StoreValue(key.id, intValue: i, strValue: s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Key for each possible value in the `Store`.
|
||||
@@ -86,11 +117,31 @@ class StoreValue {
|
||||
enum StoreKey {
|
||||
userRemoteId(0),
|
||||
assetETag(1),
|
||||
;
|
||||
currentUser(2, type: int, fromDb: _getUser, toDb: _toUser),
|
||||
deviceIdHash(3, type: int),
|
||||
deviceId(4),
|
||||
backupFailedSince(5, type: DateTime),
|
||||
backupRequireWifi(6, type: bool),
|
||||
backupRequireCharging(7, type: bool),
|
||||
backupTriggerDelay(8, type: int);
|
||||
|
||||
// ignore: unused_element
|
||||
const StoreKey(this._id, [this._isInt = false, this._fromJson]);
|
||||
final int _id;
|
||||
final bool _isInt;
|
||||
final Function(dynamic)? _fromJson;
|
||||
const StoreKey(
|
||||
this.id, {
|
||||
this.type = String,
|
||||
this.fromDb,
|
||||
this.toDb,
|
||||
// ignore: unused_element
|
||||
this.fromJson,
|
||||
});
|
||||
final int id;
|
||||
final Type type;
|
||||
final dynamic Function(Isar, int)? fromDb;
|
||||
final Future<int> Function(Isar, dynamic)? toDb;
|
||||
final Function(dynamic)? fromJson;
|
||||
}
|
||||
|
||||
User? _getUser(Isar db, int i) => db.users.getSync(i);
|
||||
Future<int> _toUser(Isar db, dynamic u) {
|
||||
User user = (u as User);
|
||||
return db.users.put(user);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,63 @@
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
part 'user.g.dart';
|
||||
|
||||
@Collection(inheritance: false)
|
||||
class User {
|
||||
User({
|
||||
required this.id,
|
||||
required this.updatedAt,
|
||||
required this.email,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.profileImagePath,
|
||||
required this.isAdmin,
|
||||
required this.oauthId,
|
||||
});
|
||||
|
||||
Id get isarId => fastHash(id);
|
||||
|
||||
User.fromDto(UserResponseDto dto)
|
||||
: id = dto.id,
|
||||
updatedAt = dto.updatedAt != null
|
||||
? DateTime.parse(dto.updatedAt!).toUtc()
|
||||
: DateTime.now().toUtc(),
|
||||
email = dto.email,
|
||||
firstName = dto.firstName,
|
||||
lastName = dto.lastName,
|
||||
profileImagePath = dto.profileImagePath,
|
||||
isAdmin = dto.isAdmin,
|
||||
oauthId = dto.oauthId;
|
||||
isAdmin = dto.isAdmin;
|
||||
|
||||
@Index(unique: true, replace: false, type: IndexType.hash)
|
||||
String id;
|
||||
DateTime updatedAt;
|
||||
String email;
|
||||
String firstName;
|
||||
String lastName;
|
||||
String profileImagePath;
|
||||
bool isAdmin;
|
||||
String oauthId;
|
||||
@Backlink(to: 'owner')
|
||||
final IsarLinks<Album> albums = IsarLinks<Album>();
|
||||
@Backlink(to: 'sharedUsers')
|
||||
final IsarLinks<Album> sharedAlbums = IsarLinks<Album>();
|
||||
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
if (other is! User) return false;
|
||||
return id == other.id &&
|
||||
updatedAt == other.updatedAt &&
|
||||
email == other.email &&
|
||||
firstName == other.firstName &&
|
||||
lastName == other.lastName &&
|
||||
profileImagePath == other.profileImagePath &&
|
||||
isAdmin == other.isAdmin &&
|
||||
oauthId == other.oauthId;
|
||||
isAdmin == other.isAdmin;
|
||||
}
|
||||
|
||||
@override
|
||||
@ignore
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
updatedAt.hashCode ^
|
||||
email.hashCode ^
|
||||
firstName.hashCode ^
|
||||
lastName.hashCode ^
|
||||
profileImagePath.hashCode ^
|
||||
isAdmin.hashCode ^
|
||||
oauthId.hashCode;
|
||||
|
||||
UserResponseDto toDto() {
|
||||
return UserResponseDto(
|
||||
id: id,
|
||||
email: email,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
profileImagePath: profileImagePath,
|
||||
createdAt: '',
|
||||
isAdmin: isAdmin,
|
||||
shouldChangePassword: false,
|
||||
oauthId: oauthId,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json["id"] = id;
|
||||
json["email"] = email;
|
||||
json["firstName"] = firstName;
|
||||
json["lastName"] = lastName;
|
||||
json["profileImagePath"] = profileImagePath;
|
||||
json["isAdmin"] = isAdmin;
|
||||
json["oauthId"] = oauthId;
|
||||
return json;
|
||||
}
|
||||
|
||||
static User? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
return User(
|
||||
id: json["id"],
|
||||
email: json["email"],
|
||||
firstName: json["firstName"],
|
||||
lastName: json["lastName"],
|
||||
profileImagePath: json["profileImagePath"],
|
||||
isAdmin: json["isAdmin"],
|
||||
oauthId: json["oauthId"],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
isAdmin.hashCode;
|
||||
}
|
||||
|
||||
1338
mobile/lib/shared/models/user.g.dart
Normal file
1338
mobile/lib/shared/models/user.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,19 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.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.service.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/exif_info.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:immich_mobile/shared/services/asset.service.dart';
|
||||
import 'package:immich_mobile/shared/services/asset_cache.service.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';
|
||||
import 'package:immich_mobile/shared/services/device_info.service.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/utils/tuple.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
@@ -50,50 +49,36 @@ class AssetsState {
|
||||
}
|
||||
}
|
||||
|
||||
class _CombineAssetsComputeParameters {
|
||||
final Iterable<Asset> local;
|
||||
final Iterable<Asset> remote;
|
||||
final String deviceId;
|
||||
|
||||
_CombineAssetsComputeParameters(this.local, this.remote, this.deviceId);
|
||||
}
|
||||
|
||||
class AssetNotifier extends StateNotifier<AssetsState> {
|
||||
final AssetService _assetService;
|
||||
final AssetCacheService _assetCacheService;
|
||||
final AppSettingsService _settingsService;
|
||||
final AlbumService _albumService;
|
||||
final Isar _db;
|
||||
final log = Logger('AssetNotifier');
|
||||
final DeviceInfoService _deviceInfoService = DeviceInfoService();
|
||||
bool _getAllAssetInProgress = false;
|
||||
bool _deleteInProgress = false;
|
||||
|
||||
AssetNotifier(
|
||||
this._assetService,
|
||||
this._assetCacheService,
|
||||
this._settingsService,
|
||||
this._albumService,
|
||||
this._db,
|
||||
) : super(AssetsState.fromAssetList([]));
|
||||
|
||||
Future<void> _updateAssetsState(
|
||||
List<Asset> newAssetList, {
|
||||
bool cache = true,
|
||||
}) async {
|
||||
if (cache) {
|
||||
_assetCacheService.put(newAssetList);
|
||||
}
|
||||
|
||||
Future<void> _updateAssetsState(List<Asset> newAssetList) async {
|
||||
final layout = AssetGridLayoutParameters(
|
||||
_settingsService.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
_settingsService.getSetting(AppSettingsEnum.dynamicLayout),
|
||||
GroupAssetsBy.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
|
||||
GroupAssetsBy
|
||||
.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
|
||||
);
|
||||
|
||||
state = await AssetsState.fromAssetList(newAssetList)
|
||||
.withRenderDataStructure(layout);
|
||||
}
|
||||
|
||||
// Just a little helper to trigger a rebuild of the state object
|
||||
Future<void> rebuildAssetGridDataStructure() async {
|
||||
await _updateAssetsState(state.allAssets, cache: false);
|
||||
await _updateAssetsState(state.allAssets);
|
||||
}
|
||||
|
||||
getAllAsset() async {
|
||||
@@ -104,127 +89,105 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
||||
final stopwatch = Stopwatch();
|
||||
try {
|
||||
_getAllAssetInProgress = true;
|
||||
bool isCacheValid = await _assetCacheService.isValid();
|
||||
final User me = Store.get(StoreKey.currentUser);
|
||||
final int cachedCount =
|
||||
await _db.assets.filter().ownerIdEqualTo(me.isarId).count();
|
||||
stopwatch.start();
|
||||
if (isCacheValid && state.allAssets.isEmpty) {
|
||||
final List<Asset>? cachedData = await _assetCacheService.get();
|
||||
if (cachedData == null) {
|
||||
isCacheValid = false;
|
||||
log.warning("Cached asset data is invalid, fetching new data");
|
||||
} else {
|
||||
await _updateAssetsState(cachedData, cache: false);
|
||||
log.info(
|
||||
"Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms",
|
||||
);
|
||||
}
|
||||
if (cachedCount > 0 && cachedCount != state.allAssets.length) {
|
||||
await _updateAssetsState(await _getUserAssets(me.isarId));
|
||||
log.info(
|
||||
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
|
||||
);
|
||||
stopwatch.reset();
|
||||
}
|
||||
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
|
||||
final remoteTask = _assetService.getRemoteAssets(
|
||||
etag: isCacheValid ? Store.get(StoreKey.assetETag) : null,
|
||||
);
|
||||
|
||||
int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote);
|
||||
remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin;
|
||||
|
||||
final List<Asset> currentLocal = state.allAssets.slice(0, remoteBegin);
|
||||
|
||||
final Pair<List<Asset>?, String?> remoteResult = await remoteTask;
|
||||
List<Asset>? newRemote = remoteResult.first;
|
||||
List<Asset>? newLocal = await localTask;
|
||||
final bool newRemote = await _assetService.refreshRemoteAssets();
|
||||
final bool newLocal = await _albumService.refreshDeviceAlbums();
|
||||
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
|
||||
stopwatch.reset();
|
||||
if (newRemote == null &&
|
||||
(newLocal == null || currentLocal.equals(newLocal))) {
|
||||
if (!newRemote &&
|
||||
!newLocal &&
|
||||
state.allAssets.length ==
|
||||
await _db.assets.filter().ownerIdEqualTo(me.isarId).count()) {
|
||||
log.info("state is already up-to-date");
|
||||
return;
|
||||
}
|
||||
newRemote ??= state.allAssets.slice(remoteBegin);
|
||||
newLocal ??= [];
|
||||
|
||||
final combinedAssets = await _combineLocalAndRemoteAssets(
|
||||
local: newLocal,
|
||||
remote: newRemote,
|
||||
);
|
||||
await _updateAssetsState(combinedAssets);
|
||||
|
||||
log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
|
||||
|
||||
Store.put(StoreKey.assetETag, remoteResult.second);
|
||||
stopwatch.reset();
|
||||
final assets = await _getUserAssets(me.isarId);
|
||||
if (!const ListEquality().equals(assets, state.allAssets)) {
|
||||
log.info("setting new asset state");
|
||||
await _updateAssetsState(assets);
|
||||
}
|
||||
} finally {
|
||||
_getAllAssetInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<Asset>> _computeCombine(
|
||||
_CombineAssetsComputeParameters data,
|
||||
) async {
|
||||
var local = data.local;
|
||||
var remote = data.remote;
|
||||
final deviceId = data.deviceId;
|
||||
Future<List<Asset>> _getUserAssets(int userId) => _db.assets
|
||||
.filter()
|
||||
.ownerIdEqualTo(userId)
|
||||
.sortByFileCreatedAtDesc()
|
||||
.findAll();
|
||||
|
||||
final List<Asset> assets = [];
|
||||
if (remote.isNotEmpty && local.isNotEmpty) {
|
||||
final Set<String> existingIds = remote
|
||||
.where((e) => e.deviceId == deviceId)
|
||||
.map((e) => e.deviceAssetId)
|
||||
.toSet();
|
||||
local = local.where((e) => !existingIds.contains(e.id));
|
||||
}
|
||||
assets.addAll(local);
|
||||
// the order (first all local, then remote assets) is important!
|
||||
assets.addAll(remote);
|
||||
return assets;
|
||||
Future<void> clearAllAsset() {
|
||||
state = AssetsState.empty();
|
||||
return _db.writeTxn(() async {
|
||||
await _db.assets.clear();
|
||||
await _db.exifInfos.clear();
|
||||
await _db.albums.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Asset>> _combineLocalAndRemoteAssets({
|
||||
required Iterable<Asset> local,
|
||||
required List<Asset> remote,
|
||||
}) async {
|
||||
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
|
||||
return await compute(
|
||||
_computeCombine,
|
||||
_CombineAssetsComputeParameters(local, remote, deviceId),
|
||||
);
|
||||
}
|
||||
|
||||
clearAllAsset() {
|
||||
_updateAssetsState([]);
|
||||
}
|
||||
|
||||
void onNewAssetUploaded(Asset newAsset) {
|
||||
Future<void> onNewAssetUploaded(Asset newAsset) async {
|
||||
final int i = state.allAssets.indexWhere(
|
||||
(a) =>
|
||||
a.isRemote ||
|
||||
(a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId),
|
||||
(a.localId == newAsset.localId && a.deviceId == newAsset.deviceId),
|
||||
);
|
||||
|
||||
if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) {
|
||||
_updateAssetsState([...state.allAssets, newAsset]);
|
||||
if (i == -1 ||
|
||||
state.allAssets[i].localId != newAsset.localId ||
|
||||
state.allAssets[i].deviceId != newAsset.deviceId) {
|
||||
await _updateAssetsState([...state.allAssets, newAsset]);
|
||||
} else {
|
||||
// unify local/remote assets by replacing the
|
||||
// local-only asset in the DB with a local&remote asset
|
||||
final Asset? inDb = await _db.assets
|
||||
.where()
|
||||
.localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId)
|
||||
.findFirst();
|
||||
if (inDb != null) {
|
||||
newAsset.id = inDb.id;
|
||||
newAsset.isLocal = inDb.isLocal;
|
||||
}
|
||||
|
||||
// order is important to keep all local-only assets at the beginning!
|
||||
_updateAssetsState([
|
||||
await _updateAssetsState([
|
||||
...state.allAssets.slice(0, i),
|
||||
...state.allAssets.slice(i + 1),
|
||||
newAsset,
|
||||
]);
|
||||
// TODO here is a place to unify local/remote assets by replacing the
|
||||
// local-only asset in the state with a local&remote asset
|
||||
}
|
||||
try {
|
||||
await _db.writeTxn(() => newAsset.put(_db));
|
||||
} on IsarError catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
deleteAssets(Set<Asset> deleteAssets) async {
|
||||
Future<void> deleteAssets(Set<Asset> deleteAssets) async {
|
||||
_deleteInProgress = true;
|
||||
try {
|
||||
_updateAssetsState(
|
||||
state.allAssets.whereNot(deleteAssets.contains).toList(),
|
||||
);
|
||||
final localDeleted = await _deleteLocalAssets(deleteAssets);
|
||||
final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
|
||||
final Set<String> deleted = HashSet();
|
||||
deleted.addAll(localDeleted);
|
||||
deleted.addAll(remoteDeleted);
|
||||
if (deleted.isNotEmpty) {
|
||||
_updateAssetsState(
|
||||
state.allAssets.where((a) => !deleted.contains(a.id)).toList(),
|
||||
);
|
||||
if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
|
||||
final dbIds = deleteAssets.map((e) => e.id).toList();
|
||||
await _db.writeTxn(() async {
|
||||
await _db.exifInfos.deleteAll(dbIds);
|
||||
await _db.assets.deleteAll(dbIds);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
_deleteInProgress = false;
|
||||
@@ -232,16 +195,15 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
||||
}
|
||||
|
||||
Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
|
||||
var deviceInfo = await _deviceInfoService.getDeviceInfo();
|
||||
var deviceId = deviceInfo["deviceId"];
|
||||
final int deviceId = Store.get(StoreKey.deviceIdHash);
|
||||
final List<String> local = [];
|
||||
// Delete asset from device
|
||||
for (final Asset asset in assetsToDelete) {
|
||||
if (asset.isLocal) {
|
||||
local.add(asset.localId!);
|
||||
local.add(asset.localId);
|
||||
} else if (asset.deviceId == deviceId) {
|
||||
// Delete asset on device if it is still present
|
||||
var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
|
||||
var localAsset = await AssetEntity.fromId(asset.localId);
|
||||
if (localAsset != null) {
|
||||
local.add(localAsset.id);
|
||||
}
|
||||
@@ -249,7 +211,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
||||
}
|
||||
if (local.isNotEmpty) {
|
||||
try {
|
||||
return await PhotoManager.editor.deleteWithIds(local);
|
||||
await PhotoManager.editor.deleteWithIds(local);
|
||||
} catch (e, stack) {
|
||||
log.severe("Failed to delete asset from device", e, stack);
|
||||
}
|
||||
@@ -289,8 +251,9 @@ class AssetNotifier extends StateNotifier<AssetsState> {
|
||||
final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
|
||||
return AssetNotifier(
|
||||
ref.watch(assetServiceProvider),
|
||||
ref.watch(assetCacheServiceProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(albumServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
|
||||
@@ -28,8 +29,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
|
||||
if (serverVersion == null) {
|
||||
state = state.copyWith(
|
||||
isVersionMismatch: true,
|
||||
versionMismatchErrorMessage:
|
||||
"Server is out of date. Some functionalities might not working correctly. Download and rebuild server",
|
||||
versionMismatchErrorMessage: "common_server_error".tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,9 +28,13 @@ class ApiService {
|
||||
debugPrint("Cannot init ApiServer endpoint, userInfoBox not open yet.");
|
||||
}
|
||||
}
|
||||
String? _authToken;
|
||||
|
||||
setEndpoint(String endpoint) {
|
||||
_apiClient = ApiClient(basePath: endpoint);
|
||||
if (_authToken != null) {
|
||||
setAccessToken(_authToken!);
|
||||
}
|
||||
userApi = UserApi(_apiClient);
|
||||
authenticationApi = AuthenticationApi(_apiClient);
|
||||
oAuthApi = OAuthApi(_apiClient);
|
||||
@@ -94,6 +98,9 @@ class ApiService {
|
||||
}
|
||||
|
||||
setAccessToken(String accessToken) {
|
||||
_authToken = accessToken;
|
||||
_apiClient.addDefaultHeader('Authorization', 'Bearer $accessToken');
|
||||
}
|
||||
|
||||
ApiClient get apiClient => _apiClient;
|
||||
}
|
||||
|
||||
@@ -1,101 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/exif_info.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/utils/openapi_extensions.dart';
|
||||
import 'package:immich_mobile/utils/tuple.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final assetServiceProvider = Provider(
|
||||
(ref) => AssetService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(backupServiceProvider),
|
||||
ref.watch(backgroundServiceProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class AssetService {
|
||||
final ApiService _apiService;
|
||||
final BackupService _backupService;
|
||||
final BackgroundService _backgroundService;
|
||||
final SyncService _syncService;
|
||||
final log = Logger('AssetService');
|
||||
final Isar _db;
|
||||
|
||||
AssetService(this._apiService, this._backupService, this._backgroundService);
|
||||
AssetService(
|
||||
this._apiService,
|
||||
this._syncService,
|
||||
this._db,
|
||||
);
|
||||
|
||||
/// Checks the server for updated assets and updates the local database if
|
||||
/// required. Returns `true` if there were any changes.
|
||||
Future<bool> refreshRemoteAssets() async {
|
||||
final Stopwatch sw = Stopwatch()..start();
|
||||
final int numOwnedRemoteAssets = await _db.assets
|
||||
.where()
|
||||
.remoteIdIsNotNull()
|
||||
.filter()
|
||||
.ownerIdEqualTo(Store.get<User>(StoreKey.currentUser)!.isarId)
|
||||
.count();
|
||||
final List<AssetResponseDto>? dtos =
|
||||
await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0);
|
||||
if (dtos == null) {
|
||||
debugPrint("refreshRemoteAssets fast took ${sw.elapsedMilliseconds}ms");
|
||||
return false;
|
||||
}
|
||||
final bool changes = await _syncService
|
||||
.syncRemoteAssetsToDb(dtos.map(Asset.remote).toList());
|
||||
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// Returns `null` if the server state did not change, else list of assets
|
||||
Future<Pair<List<Asset>?, String?>> getRemoteAssets({String? etag}) async {
|
||||
Future<List<AssetResponseDto>?> _getRemoteAssets({
|
||||
required bool hasCache,
|
||||
}) 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);
|
||||
|
||||
final etag = hasCache ? Store.get(StoreKey.assetETag) : null;
|
||||
final Pair<List<AssetResponseDto>, String?>? remote =
|
||||
await _apiService.assetApi.getAllAssetsWithETag(eTag: etag);
|
||||
if (remote == null) {
|
||||
return Pair(null, etag);
|
||||
return null;
|
||||
}
|
||||
return Pair(
|
||||
remote.first.map(Asset.remote).toList(growable: false),
|
||||
remote.second,
|
||||
);
|
||||
if (remote.second != null && remote.second != etag) {
|
||||
Store.put(StoreKey.assetETag, remote.second);
|
||||
}
|
||||
return remote.first;
|
||||
} catch (e, stack) {
|
||||
log.severe('Error while getting remote assets', e, stack);
|
||||
debugPrint("[ERROR] [getRemoteAssets] $e");
|
||||
return Pair(null, etag);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// if [urgent] is `true`, do not block by waiting on the background service
|
||||
/// to finish running. Returns `null` instead after a timeout.
|
||||
Future<List<Asset>?> getLocalAssets({bool urgent = false}) async {
|
||||
try {
|
||||
final Future<bool> hasAccess = urgent
|
||||
? _backgroundService.hasAccess
|
||||
.timeout(const Duration(milliseconds: 250))
|
||||
: _backgroundService.hasAccess;
|
||||
if (!await hasAccess) {
|
||||
throw Exception("Error [getAllAsset] failed to gain access");
|
||||
}
|
||||
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
|
||||
final String userId = Store.get(StoreKey.userRemoteId);
|
||||
if (backupAlbumInfo != null) {
|
||||
return (await _backupService
|
||||
.buildUploadCandidates(backupAlbumInfo.deepCopy()))
|
||||
.map((e) => Asset.local(e, userId))
|
||||
.toList(growable: false);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
log.severe('Error while getting local assets', e, stackTrace);
|
||||
debugPrint("Error [_getLocalAssets] ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Asset?> getAssetById(String assetId) async {
|
||||
try {
|
||||
final dto = await _apiService.assetApi.getAssetById(assetId);
|
||||
if (dto != null) {
|
||||
return Asset.remote(dto);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error [getAssetById] ${e.toString()}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<DeleteAssetResponseDto>?> deleteAssets(
|
||||
Iterable<Asset> deleteAssets,
|
||||
) async {
|
||||
@@ -114,6 +97,28 @@ class AssetService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the exif information from the database. If there is none, loads
|
||||
/// the exif info from the server (remote assets only)
|
||||
Future<Asset> loadExif(Asset a) async {
|
||||
a.exifInfo ??= await _db.exifInfos.get(a.id);
|
||||
if (a.exifInfo?.iso == null) {
|
||||
if (a.isRemote) {
|
||||
final dto = await _apiService.assetApi.getAssetById(a.remoteId!);
|
||||
if (dto != null && dto.exifInfo != null) {
|
||||
a = a.withUpdatesFromDto(dto);
|
||||
if (a.isInDb) {
|
||||
_db.writeTxn(() => a.put(_db));
|
||||
} else {
|
||||
debugPrint("[loadExif] parameter Asset is not from DB!");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO implement local exif info parsing
|
||||
}
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
Future<Asset?> updateAsset(
|
||||
Asset asset,
|
||||
UpdateAssetDto updateAssetDto,
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/services/json_cache.dart';
|
||||
|
||||
@Deprecated("only kept to remove its files after migration")
|
||||
class AssetCacheService extends JsonCache<List<Asset>> {
|
||||
AssetCacheService() : super("asset_cache");
|
||||
|
||||
static Future<List<Map<String, dynamic>>> _computeSerialize(
|
||||
List<Asset> assets,
|
||||
) async {
|
||||
return assets.map((e) => e.toJson()).toList();
|
||||
}
|
||||
@override
|
||||
void put(List<Asset> data) {}
|
||||
|
||||
@override
|
||||
void put(List<Asset> data) async {
|
||||
putRawData(await compute(_computeSerialize, data));
|
||||
}
|
||||
|
||||
static Future<List<Asset>> _computeEncode(List<dynamic> data) async {
|
||||
return data.map((e) => Asset.fromJson(e)).whereNotNull().toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Asset>?> get() async {
|
||||
try {
|
||||
final mapList = await readRawData() as List<dynamic>;
|
||||
final responseData = await compute(_computeEncode, mapList);
|
||||
return responseData;
|
||||
} catch (e) {
|
||||
debugPrint(e.toString());
|
||||
await invalidate();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Future<List<Asset>?> get() => Future.value(null);
|
||||
}
|
||||
|
||||
final assetCacheServiceProvider = Provider(
|
||||
(ref) => AssetCacheService(),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
@Deprecated("only kept to remove its files after migration")
|
||||
abstract class JsonCache<T> {
|
||||
final String cacheFileName;
|
||||
|
||||
@@ -32,33 +31,6 @@ abstract class JsonCache<T> {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> _computeEncodeJson(dynamic toEncode) async {
|
||||
return json.encode(toEncode);
|
||||
}
|
||||
|
||||
Future<void> putRawData(dynamic data) async {
|
||||
final jsonString = await compute(_computeEncodeJson, data);
|
||||
|
||||
final file = await _getCacheFile();
|
||||
|
||||
if (!await file.exists()) {
|
||||
await file.create();
|
||||
}
|
||||
|
||||
await file.writeAsString(jsonString);
|
||||
}
|
||||
|
||||
static Future<dynamic> _computeDecodeJson(String jsonString) async {
|
||||
return json.decode(jsonString);
|
||||
}
|
||||
|
||||
Future<dynamic> readRawData() async {
|
||||
final file = await _getCacheFile();
|
||||
final data = await file.readAsString();
|
||||
|
||||
return await compute(_computeDecodeJson, data);
|
||||
}
|
||||
|
||||
void put(T data);
|
||||
Future<T?> get();
|
||||
}
|
||||
|
||||
612
mobile/lib/shared/services/sync.service.dart
Normal file
612
mobile/lib/shared/services/sync.service.dart
Normal file
@@ -0,0 +1,612 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/exif_info.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:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:immich_mobile/utils/tuple.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
final syncServiceProvider =
|
||||
Provider((ref) => SyncService(ref.watch(dbProvider)));
|
||||
|
||||
class SyncService {
|
||||
final Isar _db;
|
||||
final AsyncMutex _lock = AsyncMutex();
|
||||
final Logger _log = Logger('SyncService');
|
||||
|
||||
SyncService(this._db);
|
||||
|
||||
// public methods:
|
||||
|
||||
/// Syncs users from the server to the local database
|
||||
/// Returns `true`if there were any changes
|
||||
Future<bool> syncUsersFromServer(List<User> users) async {
|
||||
users.sortBy((u) => u.id);
|
||||
final dbUsers = await _db.users.where().sortById().findAll();
|
||||
final List<int> toDelete = [];
|
||||
final List<User> toUpsert = [];
|
||||
final changes = diffSortedListsSync(
|
||||
users,
|
||||
dbUsers,
|
||||
compare: (User a, User b) => a.id.compareTo(b.id),
|
||||
both: (User a, User b) {
|
||||
if (a.updatedAt != b.updatedAt) {
|
||||
toUpsert.add(a);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (User a) => toUpsert.add(a),
|
||||
onlySecond: (User b) => toDelete.add(b.isarId),
|
||||
);
|
||||
if (changes) {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.users.deleteAll(toDelete);
|
||||
await _db.users.putAll(toUpsert);
|
||||
});
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// Syncs remote assets owned by the logged-in user to the DB
|
||||
/// Returns `true` if there were any changes
|
||||
Future<bool> syncRemoteAssetsToDb(List<Asset> remote) =>
|
||||
_lock.run(() => _syncRemoteAssetsToDb(remote));
|
||||
|
||||
/// Syncs remote albums to the database
|
||||
/// returns `true` if there were any changes
|
||||
Future<bool> syncRemoteAlbumsToDb(
|
||||
List<AlbumResponseDto> remote, {
|
||||
required bool isShared,
|
||||
required FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||
}) =>
|
||||
_lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails));
|
||||
|
||||
/// Syncs all device albums and their assets to the database
|
||||
/// Returns `true` if there were any changes
|
||||
Future<bool> syncLocalAlbumAssetsToDb(
|
||||
List<AssetPathEntity> onDevice, [
|
||||
Set<String>? excludedAssets,
|
||||
]) =>
|
||||
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets));
|
||||
|
||||
/// returns all Asset IDs that are not contained in the existing list
|
||||
List<int> sharedAssetsToRemove(
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> existing,
|
||||
) {
|
||||
if (deleteCandidates.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
deleteCandidates.sort(Asset.compareById);
|
||||
existing.sort(Asset.compareById);
|
||||
return _diffAssets(existing, deleteCandidates, compare: Asset.compareById)
|
||||
.third
|
||||
.map((e) => e.id)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// private methods:
|
||||
|
||||
/// Syncs remote assets to the databas
|
||||
/// returns `true` if there were any changes
|
||||
Future<bool> _syncRemoteAssetsToDb(List<Asset> remote) async {
|
||||
final User user = Store.get(StoreKey.currentUser);
|
||||
final List<Asset> inDb = await _db.assets
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.sortByDeviceId()
|
||||
.thenByLocalId()
|
||||
.findAll();
|
||||
remote.sort(Asset.compareByDeviceIdLocalId);
|
||||
final diff = _diffAssets(remote, inDb, remote: true);
|
||||
if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final idsToDelete = diff.third.map((e) => e.id).toList();
|
||||
try {
|
||||
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
|
||||
await _upsertAssetsWithExif(diff.first + diff.second);
|
||||
} on IsarError catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Syncs remote albums to the database
|
||||
/// returns `true` if there were any changes
|
||||
Future<bool> _syncRemoteAlbumsToDb(
|
||||
List<AlbumResponseDto> remote,
|
||||
bool isShared,
|
||||
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||
) async {
|
||||
remote.sortBy((e) => e.id);
|
||||
|
||||
final baseQuery = _db.albums.where().remoteIdIsNotNull().filter();
|
||||
final QueryBuilder<Album, Album, QAfterFilterCondition> query;
|
||||
if (isShared) {
|
||||
query = baseQuery.sharedEqualTo(true);
|
||||
} else {
|
||||
final User me = Store.get(StoreKey.currentUser);
|
||||
query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId));
|
||||
}
|
||||
final List<Album> dbAlbums = await query.sortByRemoteId().findAll();
|
||||
|
||||
final List<Asset> toDelete = [];
|
||||
final List<Asset> existing = [];
|
||||
|
||||
final bool changes = await diffSortedLists(
|
||||
remote,
|
||||
dbAlbums,
|
||||
compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!),
|
||||
both: (AlbumResponseDto a, Album b) =>
|
||||
_syncRemoteAlbum(a, b, toDelete, existing, loadDetails),
|
||||
onlyFirst: (AlbumResponseDto a) =>
|
||||
_addAlbumFromServer(a, existing, loadDetails),
|
||||
onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete),
|
||||
);
|
||||
|
||||
if (isShared && toDelete.isNotEmpty) {
|
||||
final List<int> idsToRemove = sharedAssetsToRemove(toDelete, existing);
|
||||
if (idsToRemove.isNotEmpty) {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.deleteAll(idsToRemove);
|
||||
await _db.exifInfos.deleteAll(idsToRemove);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
assert(toDelete.isEmpty);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// syncs albums from the server to the local database (does not support
|
||||
/// syncing changes from local back to server)
|
||||
/// accumulates
|
||||
Future<bool> _syncRemoteAlbum(
|
||||
AlbumResponseDto dto,
|
||||
Album album,
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> existing,
|
||||
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||
) async {
|
||||
if (!_hasAlbumResponseDtoChanged(dto, album)) {
|
||||
return false;
|
||||
}
|
||||
dto = await loadDetails(dto);
|
||||
if (dto.assetCount != dto.assets.length) {
|
||||
return false;
|
||||
}
|
||||
final assetsInDb =
|
||||
await album.assets.filter().sortByDeviceId().thenByLocalId().findAll();
|
||||
final List<Asset> assetsOnRemote = dto.getAssets();
|
||||
assetsOnRemote.sort(Asset.compareByDeviceIdLocalId);
|
||||
final d = _diffAssets(assetsOnRemote, assetsInDb);
|
||||
final List<Asset> toAdd = d.first, toUpdate = d.second, toUnlink = d.third;
|
||||
|
||||
// update shared users
|
||||
final List<User> sharedUsers = album.sharedUsers.toList(growable: false);
|
||||
sharedUsers.sort((a, b) => a.id.compareTo(b.id));
|
||||
dto.sharedUsers.sort((a, b) => a.id.compareTo(b.id));
|
||||
final List<String> userIdsToAdd = [];
|
||||
final List<User> usersToUnlink = [];
|
||||
diffSortedListsSync(
|
||||
dto.sharedUsers,
|
||||
sharedUsers,
|
||||
compare: (UserResponseDto a, User b) => a.id.compareTo(b.id),
|
||||
both: (a, b) => false,
|
||||
onlyFirst: (UserResponseDto a) => userIdsToAdd.add(a.id),
|
||||
onlySecond: (User a) => usersToUnlink.add(a),
|
||||
);
|
||||
|
||||
// for shared album: put missing album assets into local DB
|
||||
final resultPair = await _linkWithExistingFromDb(toAdd);
|
||||
await _upsertAssetsWithExif(resultPair.second);
|
||||
final assetsToLink = resultPair.first + resultPair.second;
|
||||
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
|
||||
|
||||
album.name = dto.albumName;
|
||||
album.shared = dto.shared;
|
||||
album.modifiedAt = DateTime.parse(dto.updatedAt).toUtc();
|
||||
if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) {
|
||||
album.thumbnail.value = await _db.assets
|
||||
.where()
|
||||
.remoteIdEqualTo(dto.albumThumbnailAssetId)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
// write & commit all changes to DB
|
||||
try {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(toUpdate);
|
||||
await album.thumbnail.save();
|
||||
await album.sharedUsers
|
||||
.update(link: usersToLink, unlink: usersToUnlink);
|
||||
await album.assets.update(link: assetsToLink, unlink: toUnlink.cast());
|
||||
await _db.albums.put(album);
|
||||
});
|
||||
} on IsarError catch (e) {
|
||||
debugPrint(e.toString());
|
||||
}
|
||||
|
||||
if (album.shared || dto.shared) {
|
||||
final userId = Store.get<User>(StoreKey.currentUser)!.isarId;
|
||||
final foreign =
|
||||
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
|
||||
existing.addAll(foreign);
|
||||
|
||||
// delete assets in DB unless they belong to this user or part of some other shared album
|
||||
deleteCandidates.addAll(toUnlink.where((a) => a.ownerId != userId));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Adds a remote album to the database while making sure to add any foreign
|
||||
/// (shared) assets to the database beforehand
|
||||
/// accumulates assets already existing in the database
|
||||
Future<void> _addAlbumFromServer(
|
||||
AlbumResponseDto dto,
|
||||
List<Asset> existing,
|
||||
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
|
||||
) async {
|
||||
if (dto.assetCount != dto.assets.length) {
|
||||
dto = await loadDetails(dto);
|
||||
}
|
||||
if (dto.assetCount == dto.assets.length) {
|
||||
// in case an album contains assets not yet present in local DB:
|
||||
// put missing album assets into local DB
|
||||
final result = await _linkWithExistingFromDb(dto.getAssets());
|
||||
existing.addAll(result.first);
|
||||
await _upsertAssetsWithExif(result.second);
|
||||
|
||||
final Album a = await Album.remote(dto);
|
||||
await _db.writeTxn(() => _db.albums.store(a));
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulates all suitable album assets to the `deleteCandidates` and
|
||||
/// removes the album from the database.
|
||||
Future<void> _removeAlbumFromDb(
|
||||
Album album,
|
||||
List<Asset> deleteCandidates,
|
||||
) async {
|
||||
if (album.isLocal) {
|
||||
_log.info("Removing local album $album from DB");
|
||||
// delete assets in DB unless they are remote or part of some other album
|
||||
deleteCandidates.addAll(
|
||||
await album.assets.filter().remoteIdIsNull().findAll(),
|
||||
);
|
||||
} else if (album.shared) {
|
||||
final User user = Store.get(StoreKey.currentUser);
|
||||
// delete assets in DB unless they belong to this user or are part of some other shared album
|
||||
deleteCandidates.addAll(
|
||||
await album.assets.filter().not().ownerIdEqualTo(user.isarId).findAll(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id));
|
||||
assert(ok);
|
||||
_log.info("Removed local album $album from DB");
|
||||
} catch (e) {
|
||||
_log.warning("Failed to remove local album $album from DB");
|
||||
}
|
||||
}
|
||||
|
||||
/// Syncs all device albums and their assets to the database
|
||||
/// Returns `true` if there were any changes
|
||||
Future<bool> _syncLocalAlbumAssetsToDb(
|
||||
List<AssetPathEntity> onDevice, [
|
||||
Set<String>? excludedAssets,
|
||||
]) async {
|
||||
_log.info("Syncing ${onDevice.length} albums from device: $onDevice");
|
||||
onDevice.sort((a, b) => a.id.compareTo(b.id));
|
||||
final List<Album> inDb =
|
||||
await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll();
|
||||
final List<Asset> deleteCandidates = [];
|
||||
final List<Asset> existing = [];
|
||||
final bool anyChanges = await diffSortedLists(
|
||||
onDevice,
|
||||
inDb,
|
||||
compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!),
|
||||
both: (AssetPathEntity ape, Album album) => _syncAlbumInDbAndOnDevice(
|
||||
ape,
|
||||
album,
|
||||
deleteCandidates,
|
||||
existing,
|
||||
excludedAssets,
|
||||
),
|
||||
onlyFirst: (AssetPathEntity ape) =>
|
||||
_addAlbumFromDevice(ape, existing, excludedAssets),
|
||||
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
|
||||
);
|
||||
final pair = _handleAssetRemoval(deleteCandidates, existing);
|
||||
if (pair.first.isNotEmpty || pair.second.isNotEmpty) {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.deleteAll(pair.first);
|
||||
await _db.exifInfos.deleteAll(pair.first);
|
||||
await _db.assets.putAll(pair.second);
|
||||
});
|
||||
_log.info(
|
||||
"Removed ${pair.first.length} and updated ${pair.second.length} local assets from DB",
|
||||
);
|
||||
}
|
||||
return anyChanges;
|
||||
}
|
||||
|
||||
/// Syncs the device album to the album in the database
|
||||
/// returns `true` if there were any changes
|
||||
/// Accumulates asset candidates to delete and those already existing in DB
|
||||
Future<bool> _syncAlbumInDbAndOnDevice(
|
||||
AssetPathEntity ape,
|
||||
Album album,
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> existing, [
|
||||
Set<String>? excludedAssets,
|
||||
bool forceRefresh = false,
|
||||
]) async {
|
||||
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
|
||||
return false;
|
||||
}
|
||||
if (!forceRefresh &&
|
||||
excludedAssets == null &&
|
||||
await _syncDeviceAlbumFast(ape, album)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
|
||||
final inDb = await album.assets.filter().sortByLocalId().findAll();
|
||||
final List<Asset> onDevice =
|
||||
await ape.getAssets(excludedAssets: excludedAssets);
|
||||
onDevice.sort(Asset.compareByLocalId);
|
||||
final d = _diffAssets(onDevice, inDb, compare: Asset.compareByLocalId);
|
||||
final List<Asset> toAdd = d.first, toUpdate = d.second, toDelete = d.third;
|
||||
if (toAdd.isEmpty &&
|
||||
toUpdate.isEmpty &&
|
||||
toDelete.isEmpty &&
|
||||
album.name == ape.name &&
|
||||
album.modifiedAt == ape.lastModified) {
|
||||
// changes only affeted excluded albums
|
||||
return false;
|
||||
}
|
||||
final result = await _linkWithExistingFromDb(toAdd);
|
||||
deleteCandidates.addAll(toDelete);
|
||||
existing.addAll(result.first);
|
||||
album.name = ape.name;
|
||||
album.modifiedAt = ape.lastModified!;
|
||||
if (album.thumbnail.value != null &&
|
||||
toDelete.contains(album.thumbnail.value)) {
|
||||
album.thumbnail.value = null;
|
||||
}
|
||||
try {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(result.second);
|
||||
await _db.assets.putAll(toUpdate);
|
||||
await album.assets
|
||||
.update(link: result.first + result.second, unlink: toDelete);
|
||||
await _db.albums.put(album);
|
||||
album.thumbnail.value ??= await album.assets.filter().findFirst();
|
||||
await album.thumbnail.save();
|
||||
});
|
||||
_log.info("Synced changes of local album $ape to DB");
|
||||
} on IsarError catch (e) {
|
||||
_log.warning("Failed to update synced album $ape in DB: $e");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// fast path for common case: only new assets were added to device album
|
||||
/// returns `true` if successfull, else `false`
|
||||
Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async {
|
||||
final int totalOnDevice = await ape.assetCountAsync;
|
||||
final AssetPathEntity? modified = totalOnDevice > album.assetCount
|
||||
? await ape.fetchPathProperties(
|
||||
filterOptionGroup: FilterOptionGroup(
|
||||
updateTimeCond: DateTimeCond(
|
||||
min: album.modifiedAt.add(const Duration(seconds: 1)),
|
||||
max: ape.lastModified!,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
if (modified == null) {
|
||||
return false;
|
||||
}
|
||||
final List<Asset> newAssets = await modified.getAssets();
|
||||
if (totalOnDevice != album.assets.length + newAssets.length) {
|
||||
return false;
|
||||
}
|
||||
album.modifiedAt = ape.lastModified!.toUtc();
|
||||
final result = await _linkWithExistingFromDb(newAssets);
|
||||
try {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(result.second);
|
||||
await album.assets.update(link: result.first + result.second);
|
||||
await _db.albums.put(album);
|
||||
});
|
||||
_log.info("Fast synced local album $ape to DB");
|
||||
} on IsarError catch (e) {
|
||||
_log.warning("Failed to fast sync local album $ape to DB: $e");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Adds a new album from the device to the database and Accumulates all
|
||||
/// assets already existing in the database to the list of `existing` assets
|
||||
Future<void> _addAlbumFromDevice(
|
||||
AssetPathEntity ape,
|
||||
List<Asset> existing, [
|
||||
Set<String>? excludedAssets,
|
||||
]) async {
|
||||
_log.info("Syncing a new local album to DB: $ape");
|
||||
final Album a = Album.local(ape);
|
||||
final result = await _linkWithExistingFromDb(
|
||||
await ape.getAssets(excludedAssets: excludedAssets),
|
||||
);
|
||||
_log.info(
|
||||
"${result.first.length} assets already existed in DB, to upsert ${result.second.length}",
|
||||
);
|
||||
await _upsertAssetsWithExif(result.second);
|
||||
existing.addAll(result.first);
|
||||
a.assets.addAll(result.first);
|
||||
a.assets.addAll(result.second);
|
||||
final thumb = result.first.firstOrNull ?? result.second.firstOrNull;
|
||||
a.thumbnail.value = thumb;
|
||||
try {
|
||||
await _db.writeTxn(() => _db.albums.store(a));
|
||||
_log.info("Added a new local album to DB: $ape");
|
||||
} on IsarError catch (e) {
|
||||
_log.warning("Failed to add new local album $ape to DB: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a tuple (existing, updated)
|
||||
Future<Pair<List<Asset>, List<Asset>>> _linkWithExistingFromDb(
|
||||
List<Asset> assets,
|
||||
) async {
|
||||
if (assets.isEmpty) {
|
||||
return const Pair([], []);
|
||||
}
|
||||
final List<Asset> inDb = await _db.assets
|
||||
.where()
|
||||
.anyOf(
|
||||
assets,
|
||||
(q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId),
|
||||
)
|
||||
.sortByDeviceId()
|
||||
.thenByLocalId()
|
||||
.findAll();
|
||||
assets.sort(Asset.compareByDeviceIdLocalId);
|
||||
final List<Asset> existing = [], toUpsert = [];
|
||||
diffSortedListsSync(
|
||||
inDb,
|
||||
assets,
|
||||
compare: Asset.compareByDeviceIdLocalId,
|
||||
both: (Asset a, Asset b) {
|
||||
if ((a.isLocal || !b.isLocal) &&
|
||||
(a.isRemote || !b.isRemote) &&
|
||||
a.updatedAt == b.updatedAt) {
|
||||
existing.add(a);
|
||||
return false;
|
||||
} else {
|
||||
toUpsert.add(b.updateFromDb(a));
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onlyFirst: (Asset a) => throw Exception("programming error"),
|
||||
onlySecond: (Asset b) => toUpsert.add(b),
|
||||
);
|
||||
return Pair(existing, toUpsert);
|
||||
}
|
||||
|
||||
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
||||
Future<void> _upsertAssetsWithExif(List<Asset> assets) async {
|
||||
if (assets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList();
|
||||
try {
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(assets);
|
||||
for (final Asset added in assets) {
|
||||
added.exifInfo?.id = added.id;
|
||||
}
|
||||
await _db.exifInfos.putAll(exifInfos);
|
||||
});
|
||||
_log.info("Upserted ${assets.length} assets into the DB");
|
||||
} on IsarError catch (e) {
|
||||
_log.warning(
|
||||
"Failed to upsert ${assets.length} assets into the DB: ${e.toString()}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a triple(toAdd, toUpdate, toRemove)
|
||||
Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
|
||||
List<Asset> assets,
|
||||
List<Asset> inDb, {
|
||||
bool? remote,
|
||||
int Function(Asset, Asset) compare = Asset.compareByDeviceIdLocalId,
|
||||
}) {
|
||||
final List<Asset> toAdd = [];
|
||||
final List<Asset> toUpdate = [];
|
||||
final List<Asset> toRemove = [];
|
||||
diffSortedListsSync(
|
||||
inDb,
|
||||
assets,
|
||||
compare: compare,
|
||||
both: (Asset a, Asset b) {
|
||||
if (a.updatedAt.isBefore(b.updatedAt) ||
|
||||
(!a.isLocal && b.isLocal) ||
|
||||
(!a.isRemote && b.isRemote)) {
|
||||
toUpdate.add(b.updateFromDb(a));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (Asset a) {
|
||||
if (remote == true && a.isLocal) {
|
||||
if (a.remoteId != null) {
|
||||
a.remoteId = null;
|
||||
toUpdate.add(a);
|
||||
}
|
||||
} else if (remote == false && a.isRemote) {
|
||||
if (a.isLocal) {
|
||||
a.isLocal = false;
|
||||
toUpdate.add(a);
|
||||
}
|
||||
} else {
|
||||
toRemove.add(a);
|
||||
}
|
||||
},
|
||||
onlySecond: (Asset b) => toAdd.add(b),
|
||||
);
|
||||
return Triple(toAdd, toUpdate, toRemove);
|
||||
}
|
||||
|
||||
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
|
||||
Pair<List<int>, List<Asset>> _handleAssetRemoval(
|
||||
List<Asset> deleteCandidates,
|
||||
List<Asset> existing,
|
||||
) {
|
||||
if (deleteCandidates.isEmpty) {
|
||||
return const Pair([], []);
|
||||
}
|
||||
deleteCandidates.sort(Asset.compareById);
|
||||
existing.sort(Asset.compareById);
|
||||
final triple =
|
||||
_diffAssets(existing, deleteCandidates, compare: Asset.compareById);
|
||||
return Pair(triple.third.map((e) => e.id).toList(), triple.second);
|
||||
}
|
||||
|
||||
/// returns `true` if the albums differ on the surface
|
||||
Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async {
|
||||
return a.name != b.name ||
|
||||
a.lastModified != b.modifiedAt ||
|
||||
await a.assetCountAsync != b.assetCount;
|
||||
}
|
||||
|
||||
/// returns `true` if the albums differ on the surface
|
||||
bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) {
|
||||
return dto.assetCount != a.assetCount ||
|
||||
dto.albumName != a.name ||
|
||||
dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId ||
|
||||
dto.shared != a.shared ||
|
||||
dto.sharedUsers.length != a.sharedUsers.length ||
|
||||
DateTime.parse(dto.updatedAt).toUtc() != a.modifiedAt.toUtc();
|
||||
}
|
||||
@@ -3,24 +3,32 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:image_picker/image_picker.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/utils/files_helper.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final userServiceProvider = Provider(
|
||||
(ref) => UserService(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref.watch(syncServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class UserService {
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
final SyncService _syncService;
|
||||
|
||||
UserService(this._apiService);
|
||||
UserService(this._apiService, this._db, this._syncService);
|
||||
|
||||
Future<List<User>?> getAllUsers({required bool isAll}) async {
|
||||
Future<List<User>?> _getAllUsers({required bool isAll}) async {
|
||||
try {
|
||||
final dto = await _apiService.userApi.getAllUsers(isAll);
|
||||
return dto?.map(User.fromDto).toList();
|
||||
@@ -30,6 +38,14 @@ class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<User>> getUsersInDb({bool self = false}) async {
|
||||
if (self) {
|
||||
return _db.users.where().findAll();
|
||||
}
|
||||
final int userId = Store.get<User>(StoreKey.currentUser)!.isarId;
|
||||
return _db.users.where().isarIdNotEqualTo(userId).findAll();
|
||||
}
|
||||
|
||||
Future<CreateProfileImageResponseDto?> uploadProfileImage(XFile image) async {
|
||||
try {
|
||||
var mimeType = FileHelper.getMimeType(image.path);
|
||||
@@ -50,4 +66,12 @@ class UserService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> refreshUsers() async {
|
||||
final List<User>? users = await _getAllUsers(isAll: true);
|
||||
if (users == null) {
|
||||
return false;
|
||||
}
|
||||
return _syncService.syncUsersFromServer(users);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +11,17 @@ import 'package:photo_manager/photo_manager.dart';
|
||||
class ImmichImage extends StatelessWidget {
|
||||
const ImmichImage(
|
||||
this.asset, {
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.useGrayBoxPlaceholder = false,
|
||||
super.key,
|
||||
});
|
||||
final Asset? asset;
|
||||
final bool useGrayBoxPlaceholder;
|
||||
final double width;
|
||||
final double height;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -47,7 +49,7 @@ class ImmichImage extends StatelessWidget {
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
fit: fit,
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) {
|
||||
return child;
|
||||
@@ -93,7 +95,7 @@ class ImmichImage extends StatelessWidget {
|
||||
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
|
||||
// maxHeightDiskCache = null allows to simply store the webp thumbnail
|
||||
// from the server and use it for all rendered thumbnail sizes
|
||||
fit: BoxFit.cover,
|
||||
fit: fit,
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (context, url, downloadProgress) {
|
||||
if (useGrayBoxPlaceholder) {
|
||||
|
||||
@@ -259,7 +259,6 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
||||
controller: pageOption.controller,
|
||||
scaleStateController: pageOption.scaleStateController,
|
||||
customSize: widget.customSize,
|
||||
heroAttributes: pageOption.heroAttributes,
|
||||
scaleStateChangedCallback: scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
initialScale: pageOption.initialScale,
|
||||
@@ -289,7 +288,6 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
||||
scaleStateController: pageOption.scaleStateController,
|
||||
customSize: widget.customSize,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
heroAttributes: pageOption.heroAttributes,
|
||||
scaleStateChangedCallback: scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
initialScale: pageOption.initialScale,
|
||||
@@ -310,6 +308,19 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
||||
errorBuilder: pageOption.errorBuilder,
|
||||
);
|
||||
|
||||
if (pageOption.heroAttributes != null) {
|
||||
return Hero(
|
||||
tag: pageOption.heroAttributes!.tag,
|
||||
createRectTween: pageOption.heroAttributes!.createRectTween,
|
||||
flightShuttleBuilder: pageOption.heroAttributes!.flightShuttleBuilder,
|
||||
placeholderBuilder: pageOption.heroAttributes!.placeholderBuilder,
|
||||
transitionOnUserGestures: pageOption.heroAttributes!.transitionOnUserGestures,
|
||||
child: ClipRect(
|
||||
child: photoView,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ClipRect(
|
||||
child: photoView,
|
||||
);
|
||||
|
||||
@@ -21,31 +21,30 @@ class SplashScreenPage extends HookConsumerWidget {
|
||||
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
|
||||
|
||||
void performLoggingIn() async {
|
||||
try {
|
||||
if (loginInfo != null) {
|
||||
bool isSuccess = false;
|
||||
if (loginInfo != null) {
|
||||
try {
|
||||
// Resolve API server endpoint from user provided serverUrl
|
||||
await apiService.resolveAndSetEndpoint(loginInfo.serverUrl);
|
||||
|
||||
var isSuccess = await ref
|
||||
.read(authenticationProvider.notifier)
|
||||
.setSuccessLoginInfo(
|
||||
accessToken: loginInfo.accessToken,
|
||||
serverUrl: loginInfo.serverUrl,
|
||||
);
|
||||
if (isSuccess) {
|
||||
final hasPermission = await ref
|
||||
.read(galleryPermissionNotifier.notifier)
|
||||
.hasPermission;
|
||||
if (hasPermission) {
|
||||
// Resume backup (if enable) then navigate
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
}
|
||||
AutoRouter.of(context).replace(const TabControllerRoute());
|
||||
} else {
|
||||
AutoRouter.of(context).replace(const LoginRoute());
|
||||
}
|
||||
} catch (e) {
|
||||
// okay, try to continue anyway if offline
|
||||
}
|
||||
} catch (_) {
|
||||
|
||||
isSuccess =
|
||||
await ref.read(authenticationProvider.notifier).setSuccessLoginInfo(
|
||||
accessToken: loginInfo.accessToken,
|
||||
serverUrl: loginInfo.serverUrl,
|
||||
);
|
||||
}
|
||||
if (isSuccess) {
|
||||
final hasPermission =
|
||||
await ref.read(galleryPermissionNotifier.notifier).hasPermission;
|
||||
if (hasPermission) {
|
||||
// Resume backup (if enable) then navigate
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
}
|
||||
AutoRouter.of(context).replace(const TabControllerRoute());
|
||||
} else {
|
||||
AutoRouter.of(context).replace(const LoginRoute());
|
||||
}
|
||||
}
|
||||
|
||||
16
mobile/lib/utils/async_mutex.dart
Normal file
16
mobile/lib/utils/async_mutex.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Async mutex to guarantee actions are performed sequentially and do not interleave
|
||||
class AsyncMutex {
|
||||
Future _running = Future.value(null);
|
||||
|
||||
/// Execute [operation] exclusively, after any currently running operations.
|
||||
/// Returns a [Future] with the result of the [operation].
|
||||
Future<T> run<T>(Future<T> Function() operation) {
|
||||
final completer = Completer<T>();
|
||||
_running.whenComplete(() {
|
||||
completer.complete(Future<T>.sync(operation));
|
||||
});
|
||||
return _running = completer.future;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
extension DurationExtension on String {
|
||||
Duration toDuration() {
|
||||
final parts =
|
||||
split(':').map((e) => double.parse(e).toInt()).toList(growable: false);
|
||||
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);
|
||||
Duration? toDuration() {
|
||||
try {
|
||||
final parts = split(':')
|
||||
.map((e) => double.parse(e).toInt())
|
||||
.toList(growable: false);
|
||||
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
double? toDouble() {
|
||||
return double.tryParse(this);
|
||||
double toDouble() {
|
||||
return double.parse(this);
|
||||
}
|
||||
|
||||
int toInt() {
|
||||
return int.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
71
mobile/lib/utils/diff.dart
Normal file
71
mobile/lib/utils/diff.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Efficiently compares two sorted lists in O(n), calling the given callback
|
||||
/// for each item.
|
||||
/// Return `true` if there are any differences found, else `false`
|
||||
Future<bool> diffSortedLists<A, B>(
|
||||
List<A> la,
|
||||
List<B> lb, {
|
||||
required int Function(A a, B b) compare,
|
||||
required FutureOr<bool> Function(A a, B b) both,
|
||||
required FutureOr<void> Function(A a) onlyFirst,
|
||||
required FutureOr<void> Function(B b) onlySecond,
|
||||
}) async {
|
||||
bool diff = false;
|
||||
int i = 0, j = 0;
|
||||
for (; i < la.length && j < lb.length;) {
|
||||
final int order = compare(la[i], lb[j]);
|
||||
if (order == 0) {
|
||||
diff |= await both(la[i++], lb[j++]);
|
||||
} else if (order < 0) {
|
||||
await onlyFirst(la[i++]);
|
||||
diff = true;
|
||||
} else if (order > 0) {
|
||||
await onlySecond(lb[j++]);
|
||||
diff = true;
|
||||
}
|
||||
}
|
||||
diff |= i < la.length || j < lb.length;
|
||||
for (; i < la.length; i++) {
|
||||
await onlyFirst(la[i]);
|
||||
}
|
||||
for (; j < lb.length; j++) {
|
||||
await onlySecond(lb[j]);
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
/// Efficiently compares two sorted lists in O(n), calling the given callback
|
||||
/// for each item.
|
||||
/// Return `true` if there are any differences found, else `false`
|
||||
bool diffSortedListsSync<A, B>(
|
||||
List<A> la,
|
||||
List<B> lb, {
|
||||
required int Function(A a, B b) compare,
|
||||
required bool Function(A a, B b) both,
|
||||
required void Function(A a) onlyFirst,
|
||||
required void Function(B b) onlySecond,
|
||||
}) {
|
||||
bool diff = false;
|
||||
int i = 0, j = 0;
|
||||
for (; i < la.length && j < lb.length;) {
|
||||
final int order = compare(la[i], lb[j]);
|
||||
if (order == 0) {
|
||||
diff |= both(la[i++], lb[j++]);
|
||||
} else if (order < 0) {
|
||||
onlyFirst(la[i++]);
|
||||
diff = true;
|
||||
} else if (order > 0) {
|
||||
onlySecond(lb[j++]);
|
||||
diff = true;
|
||||
}
|
||||
}
|
||||
diff |= i < la.length || j < lb.length;
|
||||
for (; i < la.length; i++) {
|
||||
onlyFirst(la[i]);
|
||||
}
|
||||
for (; j < lb.length; j++) {
|
||||
onlySecond(lb[j]);
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
15
mobile/lib/utils/hash.dart
Normal file
15
mobile/lib/utils/hash.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
/// FNV-1a 64bit hash algorithm optimized for Dart Strings
|
||||
int fastHash(String string) {
|
||||
var hash = 0xcbf29ce484222325;
|
||||
|
||||
var i = 0;
|
||||
while (i < string.length) {
|
||||
final codeUnit = string.codeUnitAt(i++);
|
||||
hash ^= codeUnit >> 8;
|
||||
hash *= 0x100000001b3;
|
||||
hash ^= codeUnit & 0xFF;
|
||||
hash *= 0x100000001b3;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
@@ -31,20 +31,20 @@ String getAlbumThumbnailUrl(
|
||||
final Album album, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
if (album.albumThumbnailAssetId == null) {
|
||||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
}
|
||||
return _getThumbnailUrl(album.albumThumbnailAssetId!, type: type);
|
||||
return _getThumbnailUrl(album.thumbnail.value!.remoteId!, type: type);
|
||||
}
|
||||
|
||||
String getAlbumThumbNailCacheKey(
|
||||
final Album album, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
if (album.albumThumbnailAssetId == null) {
|
||||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
}
|
||||
return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type);
|
||||
return _getThumbnailCacheKey(album.thumbnail.value!.remoteId!, type);
|
||||
}
|
||||
|
||||
String getImageUrl(final Asset asset) {
|
||||
|
||||
@@ -1,24 +1,114 @@
|
||||
// ignore_for_file: deprecated_member_use_from_same_package
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album_cache.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/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
Future<void> migrateHiveToStoreIfNecessary() async {
|
||||
await _migrateHiveBoxIfNecessary(userInfoBox, _migrateHiveUserInfoBox);
|
||||
await _migrateHiveBoxIfNecessary(
|
||||
backgroundBackupInfoBox,
|
||||
_migrateHiveBackgroundBackupInfoBox,
|
||||
);
|
||||
await _migrateHiveBoxIfNecessary(hiveBackupInfoBox, _migrateBackupInfoBox);
|
||||
await _migrateHiveBoxIfNecessary(
|
||||
duplicatedAssetsBox,
|
||||
_migrateDuplicatedAssetsBox,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _migrateHiveUserInfoBox(Box box) async {
|
||||
await _migrateKey(box, userIdKey, StoreKey.userRemoteId);
|
||||
await _migrateKey(box, assetEtagKey, StoreKey.assetETag);
|
||||
}
|
||||
|
||||
Future<void> _migrateHiveBackgroundBackupInfoBox(Box box) async {
|
||||
await _migrateKey(box, backupFailedSince, StoreKey.backupFailedSince);
|
||||
await _migrateKey(box, backupRequireWifi, StoreKey.backupRequireWifi);
|
||||
await _migrateKey(box, backupRequireCharging, StoreKey.backupRequireCharging);
|
||||
await _migrateKey(box, backupTriggerDelay, StoreKey.backupTriggerDelay);
|
||||
return box.deleteFromDisk();
|
||||
}
|
||||
|
||||
Future<void> _migrateBackupInfoBox(Box<HiveBackupAlbums> box) async {
|
||||
final Isar? db = Isar.getInstance();
|
||||
if (db == null) {
|
||||
throw Exception("_migrateBackupInfoBox could not load database");
|
||||
}
|
||||
final HiveBackupAlbums? infos = box.get(backupInfoKey);
|
||||
if (infos != null) {
|
||||
List<BackupAlbum> albums = [];
|
||||
for (int i = 0; i < infos.selectedAlbumIds.length; i++) {
|
||||
final album = BackupAlbum(
|
||||
infos.selectedAlbumIds[i],
|
||||
infos.lastSelectedBackupTime[i],
|
||||
BackupSelection.select,
|
||||
);
|
||||
albums.add(album);
|
||||
}
|
||||
for (int i = 0; i < infos.excludedAlbumsIds.length; i++) {
|
||||
final album = BackupAlbum(
|
||||
infos.excludedAlbumsIds[i],
|
||||
infos.lastExcludedBackupTime[i],
|
||||
BackupSelection.exclude,
|
||||
);
|
||||
albums.add(album);
|
||||
}
|
||||
await db.writeTxn(() => db.backupAlbums.putAll(albums));
|
||||
} else {
|
||||
debugPrint("_migrateBackupInfoBox deletes empty box");
|
||||
}
|
||||
return box.deleteFromDisk();
|
||||
}
|
||||
|
||||
Future<void> _migrateDuplicatedAssetsBox(Box<HiveDuplicatedAssets> box) async {
|
||||
final Isar? db = Isar.getInstance();
|
||||
if (db == null) {
|
||||
throw Exception("_migrateBackupInfoBox could not load database");
|
||||
}
|
||||
final HiveDuplicatedAssets? duplicatedAssets = box.get(duplicatedAssetsKey);
|
||||
if (duplicatedAssets != null) {
|
||||
final duplicatedAssetIds = duplicatedAssets.duplicatedAssetIds
|
||||
.map((id) => DuplicatedAsset(id))
|
||||
.toList();
|
||||
await db.writeTxn(() => db.duplicatedAssets.putAll(duplicatedAssetIds));
|
||||
} else {
|
||||
debugPrint("_migrateDuplicatedAssetsBox deletes empty box");
|
||||
}
|
||||
return box.deleteFromDisk();
|
||||
}
|
||||
|
||||
Future<void> _migrateHiveBoxIfNecessary<T>(
|
||||
String boxName,
|
||||
Future<void> Function(Box<T>) migrate,
|
||||
) async {
|
||||
try {
|
||||
if (await Hive.boxExists(userInfoBox)) {
|
||||
final Box box = await Hive.openBox(userInfoBox);
|
||||
await _migrateSingleKey(box, userIdKey, StoreKey.userRemoteId);
|
||||
await _migrateSingleKey(box, assetEtagKey, StoreKey.assetETag);
|
||||
if (await Hive.boxExists(boxName)) {
|
||||
await migrate(await Hive.openBox<T>(boxName));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error while migrating userInfoBox $e");
|
||||
debugPrint("Error while migrating $boxName $e");
|
||||
}
|
||||
}
|
||||
|
||||
_migrateSingleKey(Box box, String hiveKey, StoreKey key) async {
|
||||
_migrateKey(Box box, String hiveKey, StoreKey key) async {
|
||||
final String? value = box.get(hiveKey);
|
||||
if (value != null) {
|
||||
await Store.put(key, value);
|
||||
await box.delete(hiveKey);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> migrateJsonCacheIfNecessary() async {
|
||||
await AlbumCacheService().invalidate();
|
||||
await SharedAlbumCacheService().invalidate();
|
||||
await AssetCacheService().invalidate();
|
||||
}
|
||||
|
||||
@@ -6,3 +6,13 @@ class Pair<T1, T2> {
|
||||
|
||||
const Pair(this.first, this.second);
|
||||
}
|
||||
|
||||
/// An immutable triple or 3-tuple
|
||||
/// TODO replace with Record once Dart 2.19 is available
|
||||
class Triple<T1, T2, T3> {
|
||||
final T1 first;
|
||||
final T2 second;
|
||||
final T3 third;
|
||||
|
||||
const Triple(this.first, this.second, this.third);
|
||||
}
|
||||
|
||||
39
mobile/openapi/.openapi-generator/FILES
generated
39
mobile/openapi/.openapi-generator/FILES
generated
@@ -51,8 +51,8 @@ doc/GetAssetCountByTimeBucketDto.md
|
||||
doc/JobApi.md
|
||||
doc/JobCommand.md
|
||||
doc/JobCommandDto.md
|
||||
doc/JobCounts.md
|
||||
doc/JobId.md
|
||||
doc/JobCountsDto.md
|
||||
doc/JobName.md
|
||||
doc/LoginCredentialDto.md
|
||||
doc/LoginResponseDto.md
|
||||
doc/LogoutResponseDto.md
|
||||
@@ -61,7 +61,16 @@ doc/OAuthCallbackDto.md
|
||||
doc/OAuthConfigDto.md
|
||||
doc/OAuthConfigResponseDto.md
|
||||
doc/RemoveAssetsDto.md
|
||||
doc/SearchAlbumResponseDto.md
|
||||
doc/SearchApi.md
|
||||
doc/SearchAssetDto.md
|
||||
doc/SearchAssetResponseDto.md
|
||||
doc/SearchConfigResponseDto.md
|
||||
doc/SearchExploreItem.md
|
||||
doc/SearchExploreResponseDto.md
|
||||
doc/SearchFacetCountResponseDto.md
|
||||
doc/SearchFacetResponseDto.md
|
||||
doc/SearchResponseDto.md
|
||||
doc/ServerInfoApi.md
|
||||
doc/ServerInfoResponseDto.md
|
||||
doc/ServerPingResponse.md
|
||||
@@ -103,6 +112,7 @@ lib/api/authentication_api.dart
|
||||
lib/api/device_info_api.dart
|
||||
lib/api/job_api.dart
|
||||
lib/api/o_auth_api.dart
|
||||
lib/api/search_api.dart
|
||||
lib/api/server_info_api.dart
|
||||
lib/api/share_api.dart
|
||||
lib/api/system_config_api.dart
|
||||
@@ -158,8 +168,8 @@ lib/model/get_asset_by_time_bucket_dto.dart
|
||||
lib/model/get_asset_count_by_time_bucket_dto.dart
|
||||
lib/model/job_command.dart
|
||||
lib/model/job_command_dto.dart
|
||||
lib/model/job_counts.dart
|
||||
lib/model/job_id.dart
|
||||
lib/model/job_counts_dto.dart
|
||||
lib/model/job_name.dart
|
||||
lib/model/login_credential_dto.dart
|
||||
lib/model/login_response_dto.dart
|
||||
lib/model/logout_response_dto.dart
|
||||
@@ -167,7 +177,15 @@ lib/model/o_auth_callback_dto.dart
|
||||
lib/model/o_auth_config_dto.dart
|
||||
lib/model/o_auth_config_response_dto.dart
|
||||
lib/model/remove_assets_dto.dart
|
||||
lib/model/search_album_response_dto.dart
|
||||
lib/model/search_asset_dto.dart
|
||||
lib/model/search_asset_response_dto.dart
|
||||
lib/model/search_config_response_dto.dart
|
||||
lib/model/search_explore_item.dart
|
||||
lib/model/search_explore_response_dto.dart
|
||||
lib/model/search_facet_count_response_dto.dart
|
||||
lib/model/search_facet_response_dto.dart
|
||||
lib/model/search_response_dto.dart
|
||||
lib/model/server_info_response_dto.dart
|
||||
lib/model/server_ping_response.dart
|
||||
lib/model/server_stats_response_dto.dart
|
||||
@@ -244,8 +262,8 @@ test/get_asset_count_by_time_bucket_dto_test.dart
|
||||
test/job_api_test.dart
|
||||
test/job_command_dto_test.dart
|
||||
test/job_command_test.dart
|
||||
test/job_counts_test.dart
|
||||
test/job_id_test.dart
|
||||
test/job_counts_dto_test.dart
|
||||
test/job_name_test.dart
|
||||
test/login_credential_dto_test.dart
|
||||
test/login_response_dto_test.dart
|
||||
test/logout_response_dto_test.dart
|
||||
@@ -254,7 +272,16 @@ test/o_auth_callback_dto_test.dart
|
||||
test/o_auth_config_dto_test.dart
|
||||
test/o_auth_config_response_dto_test.dart
|
||||
test/remove_assets_dto_test.dart
|
||||
test/search_album_response_dto_test.dart
|
||||
test/search_api_test.dart
|
||||
test/search_asset_dto_test.dart
|
||||
test/search_asset_response_dto_test.dart
|
||||
test/search_config_response_dto_test.dart
|
||||
test/search_explore_item_test.dart
|
||||
test/search_explore_response_dto_test.dart
|
||||
test/search_facet_count_response_dto_test.dart
|
||||
test/search_facet_response_dto_test.dart
|
||||
test/search_response_dto_test.dart
|
||||
test/server_info_api_test.dart
|
||||
test/server_info_response_dto_test.dart
|
||||
test/server_ping_response_test.dart
|
||||
|
||||
17
mobile/openapi/README.md
generated
17
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.49.0
|
||||
- API version: 1.51.1
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
## Requirements
|
||||
@@ -121,6 +121,9 @@ Class | Method | HTTP request | Description
|
||||
*OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link |
|
||||
*OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect |
|
||||
*OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink |
|
||||
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |
|
||||
*SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config |
|
||||
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
|
||||
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |
|
||||
*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
|
||||
*ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats |
|
||||
@@ -195,8 +198,8 @@ Class | Method | HTTP request | Description
|
||||
- [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)
|
||||
- [JobCommand](doc//JobCommand.md)
|
||||
- [JobCommandDto](doc//JobCommandDto.md)
|
||||
- [JobCounts](doc//JobCounts.md)
|
||||
- [JobId](doc//JobId.md)
|
||||
- [JobCountsDto](doc//JobCountsDto.md)
|
||||
- [JobName](doc//JobName.md)
|
||||
- [LoginCredentialDto](doc//LoginCredentialDto.md)
|
||||
- [LoginResponseDto](doc//LoginResponseDto.md)
|
||||
- [LogoutResponseDto](doc//LogoutResponseDto.md)
|
||||
@@ -204,7 +207,15 @@ Class | Method | HTTP request | Description
|
||||
- [OAuthConfigDto](doc//OAuthConfigDto.md)
|
||||
- [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
|
||||
- [RemoveAssetsDto](doc//RemoveAssetsDto.md)
|
||||
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
|
||||
- [SearchAssetDto](doc//SearchAssetDto.md)
|
||||
- [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
|
||||
- [SearchConfigResponseDto](doc//SearchConfigResponseDto.md)
|
||||
- [SearchExploreItem](doc//SearchExploreItem.md)
|
||||
- [SearchExploreResponseDto](doc//SearchExploreResponseDto.md)
|
||||
- [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md)
|
||||
- [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
|
||||
- [SearchResponseDto](doc//SearchResponseDto.md)
|
||||
- [ServerInfoResponseDto](doc//ServerInfoResponseDto.md)
|
||||
- [ServerPingResponse](doc//ServerPingResponse.md)
|
||||
- [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
|
||||
|
||||
13
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
13
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
@@ -8,11 +8,14 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**thumbnailGeneration** | [**JobCounts**](JobCounts.md) | |
|
||||
**metadataExtraction** | [**JobCounts**](JobCounts.md) | |
|
||||
**videoConversion** | [**JobCounts**](JobCounts.md) | |
|
||||
**machineLearning** | [**JobCounts**](JobCounts.md) | |
|
||||
**storageTemplateMigration** | [**JobCounts**](JobCounts.md) | |
|
||||
**thumbnailGenerationQueue** | [**JobCountsDto**](JobCountsDto.md) | |
|
||||
**metadataExtractionQueue** | [**JobCountsDto**](JobCountsDto.md) | |
|
||||
**videoConversionQueue** | [**JobCountsDto**](JobCountsDto.md) | |
|
||||
**objectTaggingQueue** | [**JobCountsDto**](JobCountsDto.md) | |
|
||||
**clipEncodingQueue** | [**JobCountsDto**](JobCountsDto.md) | |
|
||||
**storageTemplateMigrationQueue** | [**JobCountsDto**](JobCountsDto.md) | |
|
||||
**backgroundTaskQueue** | [**JobCountsDto**](JobCountsDto.md) | |
|
||||
**searchQueue** | [**JobCountsDto**](JobCountsDto.md) | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
13
mobile/openapi/doc/JobApi.md
generated
13
mobile/openapi/doc/JobApi.md
generated
@@ -63,7 +63,7 @@ This endpoint does not need any parameter.
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **sendJobCommand**
|
||||
> num sendJobCommand(jobId, jobCommandDto)
|
||||
> sendJobCommand(jobId, jobCommandDto)
|
||||
|
||||
|
||||
|
||||
@@ -84,12 +84,11 @@ import 'package:openapi/api.dart';
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
|
||||
final api_instance = JobApi();
|
||||
final jobId = ; // JobId |
|
||||
final jobId = ; // JobName |
|
||||
final jobCommandDto = JobCommandDto(); // JobCommandDto |
|
||||
|
||||
try {
|
||||
final result = api_instance.sendJobCommand(jobId, jobCommandDto);
|
||||
print(result);
|
||||
api_instance.sendJobCommand(jobId, jobCommandDto);
|
||||
} catch (e) {
|
||||
print('Exception when calling JobApi->sendJobCommand: $e\n');
|
||||
}
|
||||
@@ -99,12 +98,12 @@ try {
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**jobId** | [**JobId**](.md)| |
|
||||
**jobId** | [**JobName**](.md)| |
|
||||
**jobCommandDto** | [**JobCommandDto**](JobCommandDto.md)| |
|
||||
|
||||
### Return type
|
||||
|
||||
**num**
|
||||
void (empty response body)
|
||||
|
||||
### Authorization
|
||||
|
||||
@@ -113,7 +112,7 @@ Name | Type | Description | Notes
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: application/json
|
||||
- **Accept**: application/json
|
||||
- **Accept**: Not defined
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
2
mobile/openapi/doc/JobCommandDto.md
generated
2
mobile/openapi/doc/JobCommandDto.md
generated
@@ -9,7 +9,7 @@ import 'package:openapi/api.dart';
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**command** | [**JobCommand**](JobCommand.md) | |
|
||||
**includeAllAssets** | **bool** | |
|
||||
**force** | **bool** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user