mirror of
https://github.com/immich-app/immich.git
synced 2025-12-08 13:51:02 -08:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d77a1aba7a | ||
|
|
9e21b16553 | ||
|
|
dcb56ae775 | ||
|
|
ab2c019a7a | ||
|
|
eb408d4858 | ||
|
|
4f38851880 | ||
|
|
2c356ec87f | ||
|
|
bb84464216 | ||
|
|
32b9e0bad4 | ||
|
|
02f5a86ee9 | ||
|
|
391bf052e4 | ||
|
|
d2a9363fc5 | ||
|
|
68af4cd5ba | ||
|
|
c82dcb11e1 | ||
|
|
d0f8d8d1f9 | ||
|
|
6a852332de | ||
|
|
830fec0c29 | ||
|
|
aa68d35f42 | ||
|
|
6e6fe9bc87 | ||
|
|
29f68e6dbb | ||
|
|
9428b2576b | ||
|
|
3210302ecd | ||
|
|
f23979024a | ||
|
|
870a65fa6d | ||
|
|
199eb20b66 | ||
|
|
91114e5aa0 | ||
|
|
dfbc831525 | ||
|
|
1a640609c7 | ||
|
|
00630bd4a3 | ||
|
|
fb408d7aa3 | ||
|
|
6b5d6e4091 | ||
|
|
a287be1f0c | ||
|
|
42a3149fe3 | ||
|
|
5aee5c0fb8 | ||
|
|
12ecf366b0 | ||
|
|
275562bce0 | ||
|
|
a09030fd6d | ||
|
|
414893a687 | ||
|
|
5939d79057 | ||
|
|
189bd37e71 | ||
|
|
715056047c | ||
|
|
0220f900c1 |
19
.editorconfig
Normal file
19
.editorconfig
Normal file
@@ -0,0 +1,19 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{ts,js}]
|
||||
quote_type = single
|
||||
|
||||
[*.{md,mdx}]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
quote_type = double
|
||||
53
.github/workflows/build-mobile.yml
vendored
Normal file
53
.github/workflows/build-mobile.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Build Mobile
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-sign-android:
|
||||
name: Build and sign Android
|
||||
runs-on: macos-12
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "12.x"
|
||||
cache: "gradle"
|
||||
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.3.10"
|
||||
cache: true
|
||||
|
||||
- name: Create the Keystore
|
||||
env:
|
||||
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||
working-directory: ./mobile
|
||||
run: echo $KEY_JKS | base64 -d > android/key.jks
|
||||
|
||||
- name: Get Packages
|
||||
working-directory: ./mobile
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build Android App Bundle
|
||||
working-directory: ./mobile
|
||||
env:
|
||||
ALIAS: ${{ secrets.ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Publish Android Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-apk-signed
|
||||
path: mobile/build/app/outputs/flutter-apk/app-release.apk
|
||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v2.1.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2.2.1
|
||||
uses: docker/setup-buildx-action@v2.4.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
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v3.3.0
|
||||
uses: docker/build-push-action@v4.0.0
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||
|
||||
29
.github/workflows/prepare-release.yml
vendored
29
.github/workflows/prepare-release.yml
vendored
@@ -4,23 +4,28 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
serverBump:
|
||||
description: 'Bump server version'
|
||||
description: "Bump server version"
|
||||
required: true
|
||||
default: 'false'
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- false
|
||||
- minor
|
||||
- patch
|
||||
- "false"
|
||||
- minor
|
||||
- patch
|
||||
mobileBump:
|
||||
description: 'Bump mobile build number'
|
||||
description: "Bump mobile build number"
|
||||
required: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
build_mobile:
|
||||
uses: ./.github/workflows/build-mobile.yml
|
||||
secrets: inherit
|
||||
|
||||
tag_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: build_mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
@@ -29,7 +34,7 @@ jobs:
|
||||
|
||||
- name: Bump version
|
||||
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
|
||||
|
||||
|
||||
- name: Commit and tag
|
||||
uses: EndBug/add-and-commit@v9
|
||||
with:
|
||||
@@ -38,7 +43,12 @@ jobs:
|
||||
message: "Version ${{ env.IMMICH_VERSION }}"
|
||||
tag: ${{ env.IMMICH_VERSION }}
|
||||
push: true
|
||||
|
||||
|
||||
- name: Download APK
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-apk-signed
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
@@ -49,3 +59,4 @@ jobs:
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/example.env
|
||||
*.apk
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
name: Run mobile unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Setup Flutter SDK
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
name: Run mobile end-to-end integration tests
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'adopt'
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ uploads
|
||||
coverage
|
||||
|
||||
mobile/gradle.properties
|
||||
mobile/openapi/pubspec.lock
|
||||
mobile/*.jks
|
||||
|
||||
@@ -13,11 +13,11 @@ Download [`docker-compose.yml`][compose-file] [`example.env`][env-file].
|
||||
From a directory of your choice (e.g. `./immich-app`) run the following commands:
|
||||
|
||||
```bash title="Get docker-compose.yml file"
|
||||
wget https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml
|
||||
wget https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
|
||||
```
|
||||
|
||||
```bash title="Get .env file"
|
||||
wget -O .env https://raw.githubusercontent.com/immich-app/immich/main/docker/example.env
|
||||
wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env
|
||||
```
|
||||
|
||||
### Step 2 - Populate the .env file with custom values
|
||||
@@ -116,6 +116,6 @@ docker-compose pull && docker-compose up -d # Or `docker compose`
|
||||
Immich is currently under heavy development, which means you can expect breaking changes and bugs. Therefore, we recommend reading the release notes prior to updating and to take special care when using automated tools like [Watchtower][watchtower].
|
||||
:::
|
||||
|
||||
[compose-file]: https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml
|
||||
[env-file]: https://raw.githubusercontent.com/immich-app/immich/main/docker/example.env
|
||||
[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
|
||||
[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env
|
||||
[watchtower]: https://containrrr.dev/watchtower/
|
||||
|
||||
@@ -9,7 +9,7 @@ Install Immich using Portainer's Stack feature.
|
||||
1. Go to "**Stacks**" in the left sidebar.
|
||||
2. Click on "**Add stack**".
|
||||
3. Give the stack a name (i.e. Immich), and select "**Web Editor**" as the build method.
|
||||
4. Copy the content of the `docker-compose.yml` file from the [GitHub repository](https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml).
|
||||
4. Copy the content of the `docker-compose.yml` file from the [GitHub repository](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml).
|
||||
5. Replace `.env` with `stack.env` for all containers that need to use environment variables in the web editor.
|
||||
|
||||
<img
|
||||
@@ -28,7 +28,7 @@ Install Immich using Portainer's Stack feature.
|
||||
alt="Dot Env Example"
|
||||
/>
|
||||
|
||||
9. Copy the content of the `example.env` file from the [GitHub repository](https://raw.githubusercontent.com/immich-app/immich/main/docker/example.env) and paste into the editor.
|
||||
9. Copy the content of the `example.env` file from the [GitHub repository](https://github.com/immich-app/immich/releases/latest/download/example.env) and paste into the editor.
|
||||
10. Switch back to "**Simple Mode**".
|
||||
|
||||
<img
|
||||
|
||||
@@ -16,7 +16,7 @@ curl -o- https://raw.githubusercontent.com/immich-app/immich/main/install.sh | b
|
||||
|
||||
The script will perform the following actions:
|
||||
|
||||
1. Download [docker-compose.yml](https://github.com/immich-app/immich/blob/main/docker/docker-compose.yml), and the [.env](https://github.com/immich-app/immich/blob/main/docker/example.env) file from the main branch of the [repository](https://github.com/immich-app/immich).
|
||||
1. Download [docker-compose.yml](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml), and the [.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file from the main branch of the [repository](https://github.com/immich-app/immich).
|
||||
2. Populate the `.env` file with necessary information based on the current directory path.
|
||||
3. Start the containers.
|
||||
|
||||
|
||||
@@ -4,7 +4,25 @@ sidebar_position: 60
|
||||
|
||||
# Unraid
|
||||
|
||||
Immich can easily be installed and updated on Unraid using the [Docker Compose Manager](https://forums.unraid.net/topic/114415-plugin-docker-compose-manager/) plugin from the Unraid Community Apps.
|
||||
Immich can easily be installed and updated on Unraid via:
|
||||
1. [Docker Compose Manager](https://forums.unraid.net/topic/114415-plugin-docker-compose-manager/) plugin from the Unraid Community Apps
|
||||
2. Community made template on the Unraid Community Apps
|
||||
|
||||
## Community Applications Template
|
||||
|
||||
:::info
|
||||
|
||||
- The Unraid template uses a community made image and is not officially supported by Immich
|
||||
|
||||
:::
|
||||
|
||||
In order to install Immich from the Unraid CA, you will need an existing Redis and PostgreSQL 14 container, If you do not already have Redis or PostgreSQL you can install them from the Unraid CA, just make sure you choose PostgreSQL **14**.
|
||||
|
||||
Once you have Redis and PostgreSQL running, search for Immich on the Unraid CA, Choose either of the templates listed and fill out the example variables.
|
||||
|
||||
For more information about setting up the community image see [here](https://github.com/imagegenius/docker-immich#application-setup)
|
||||
|
||||
## Docker-Compose Method (Official)
|
||||
|
||||
:::info
|
||||
|
||||
@@ -27,7 +45,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
|
||||
/>
|
||||
|
||||
3. Select the cog ⚙️ next to Immich then click "**Edit Stack**"
|
||||
4. Click "**Compose File**" and then paste the entire contents of the [Immich Docker Compose](https://raw.githubusercontent.com/immich-app/immich/main/docker/docker-compose.yml) file into the Unraid editor
|
||||
4. Click "**Compose File**" and then paste the entire contents of the [Immich Docker Compose](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) file into the Unraid editor
|
||||
<details >
|
||||
<summary>Using an existing Postgres container? Click me! Otherwise proceed to step 5.</summary>
|
||||
<ul>
|
||||
@@ -53,7 +71,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
|
||||
</details>
|
||||
5. Click "**Save Changes**", you will be promoted to edit stack UI labels, just leave this blank and click "**Ok**"
|
||||
6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**"
|
||||
7. Past the entire contents of the [Immich example.env](https://raw.githubusercontent.com/immich-app/immich/main/docker/example.env) file into the Unraid editor, then **before saving** edit the following:
|
||||
7. Past the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following:
|
||||
|
||||
- `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION`
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m>
|
||||
#
|
||||
# examples:
|
||||
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
||||
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
|
||||
# ./scripts/pump-version.sh -s minor -m # 1.0.0+50 => 1.1.0+51
|
||||
# ./scripts/pump-version.sh -m # 1.0.0+50 => 1.0.0+51
|
||||
#
|
||||
@@ -16,10 +16,12 @@ MOBILE_PUMP="false"
|
||||
|
||||
while getopts 's:m:' flag; do
|
||||
case "${flag}" in
|
||||
s) SERVER_PUMP=${OPTARG} ;;
|
||||
m) MOBILE_PUMP=${OPTARG} ;;
|
||||
*) echo "Invalid args"
|
||||
exit 1 ;;
|
||||
s) SERVER_PUMP=${OPTARG} ;;
|
||||
m) MOBILE_PUMP=${OPTARG} ;;
|
||||
*)
|
||||
echo "Invalid args"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -30,8 +32,11 @@ PATCH=$(echo $CURRENT_SERVER | cut -d '.' -f3)
|
||||
|
||||
if [[ $SERVER_PUMP == "major" ]]; then
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
elif [[ $SERVER_PUMP == "minor" ]]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
elif [[ $SERVER_PUMP == "patch" ]]; then
|
||||
PATCH=$((PATCH + 1))
|
||||
elif [[ $SERVER_PUMP == "false" ]]; then
|
||||
@@ -54,8 +59,6 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
@@ -63,10 +66,9 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
sed -i "s/^ \"version\": \"$CURRENT_SERVER\",$/ \"version\": \"$NEXT_SERVER\",/" server/package.json
|
||||
sed -i "s/^ \"version\": \"$CURRENT_SERVER\",$/ \"version\": \"$NEXT_SERVER\",/" server/package-lock.json
|
||||
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
|
||||
sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
||||
|
||||
echo "Pumping Mobile: $CURRENT_MOBILE => $NEXT_MOBILE"
|
||||
@@ -75,4 +77,4 @@ if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
||||
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
|
||||
fi
|
||||
|
||||
echo "IMMICH_VERSION=v$NEXT_SERVER" >> $GITHUB_ENV
|
||||
echo "IMMICH_VERSION=v$NEXT_SERVER" >>$GITHUB_ENV
|
||||
|
||||
@@ -59,17 +59,19 @@ android {
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
||||
storePassword keystoreProperties['storePassword']
|
||||
def keyAliasVal = System.getenv("ALIAS")
|
||||
def keyPasswordVal = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
def storePasswordVal = System.getenv("ANDROID_STORE_PASSWORD")
|
||||
|
||||
keyAlias keyAliasVal ? keyAliasVal : keystoreProperties['keyAlias']
|
||||
keyPassword keyPasswordVal ? keyPasswordVal : keystoreProperties['keyPassword']
|
||||
storeFile file("../key.jks") ? file("../key.jks") : file(keystoreProperties['storeFile'])
|
||||
storePassword storePasswordVal ? storePasswordVal : keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<vector android:height="200dp"
|
||||
android:viewportHeight="1300"
|
||||
android:viewportWidth="1300"
|
||||
android:width="200dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="#4081ef"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578,922.6c-2.2,-0.2 -5.5,-1 -9.7,-2.2c-52.4,-15.7 -99,-46.5 -133.8,-88.5c-8.8,-10.7 -17.2,-22.4 -19.4,-27.5c-8.1,-18.1 -6.3,-38.7 4.8,-55.4c5,-7.5 13.2,-15 20.5,-18.7c1.2,-0.6 54.1,-20 55.8,-20.4c0.5,-0.1 0.5,0.2 -0.3,2.1c-0.7,1.7 -1,3.1 -1.1,5.5l-0.1,3.2l2.8,5.8c8.7,17.9 19.2,32.7 33.2,46.4c6.3,6.2 7.8,7.6 13.8,12.3c22.7,18.1 52,30.7 79.9,34.3c2.5,0.3 5,0.8 5.7,1c2.8,0.9 7.7,-0.8 11,-3.7l1.8,-1.6l-0.2,4.8c-0.1,2.7 -0.6,15.4 -1,28.3c-0.6,20.3 -0.8,24 -1.5,27.5c-3.9,20.7 -18.6,37.5 -38.4,44.1c-4.6,1.5 -8,2.2 -13.1,2.7c-4.6,0.5 -5.9,0.4 -10.7,0Z" />
|
||||
<path
|
||||
android:fillColor="#31a452"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M707.3,922.4c-4,-0.4 -9.4,-1.6 -13.2,-2.9c-3.4,-1.2 -10,-4.4 -12.5,-6.1c-10.9,-7.4 -19,-17.9 -23.1,-30c-2.2,-6.7 -2.3,-7.5 -3.3,-36.9c-0.5,-14.9 -0.9,-27.9 -0.9,-28.9l-0,-1.9l2.3,1.8c2.6,2 6.6,3.4 8.5,3.1c0.6,-0.1 3,-0.5 5.3,-0.8c37.7,-5.3 71.2,-22.2 97.4,-49.1c12.2,-12.5 21.4,-25.5 29.9,-42.4l3.5,-7l0,-3.6c0,-3.1 -0.1,-3.8 -1,-5.7c-0.5,-1.2 -0.9,-2.1 -0.9,-2.2c0.2,-0.2 55.3,20.1 56.9,20.9c2.6,1.3 6.6,4.1 9.9,7c9.2,7.7 16.1,19.4 18.8,31.8c0.7,3.1 0.8,4.8 0.8,11.3c0,8.6 -0.5,11.7 -2.9,18.7c-1.7,5 -2.9,7.2 -7.1,13.1c-7.6,11 -15.3,20.5 -25.2,31.2c-32.8,35.4 -76.5,62.5 -123.4,76.3c-8,2.5 -12.4,3 -19.8,2.3Z" />
|
||||
<path
|
||||
android:fillColor="#de7fb3"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M623.1,811c-25.9,-4.2 -50.7,-14.9 -71.7,-31c-5.2,-4 -8.7,-7.1 -14.1,-12.4c-12.7,-12.5 -21.9,-24.9 -30.5,-41.4c-2.3,-4.4 -2.4,-4.7 -2.4,-7.1c0,-8.8 8.5,-15.2 16.9,-12.7c5.6,1.7 9.6,6.8 9.7,12.2c0,2.6 -0.8,4.6 -2.6,6.2c-1.2,1.1 -3.2,1.9 -4.6,1.9c-1.2,0 -3.3,-0.8 -4.3,-1.6c-2.1,-1.8 -2,-1 0.4,3.2c19.3,33.8 52.3,59.1 90,69.1c5.7,1.5 11.5,2.7 11.8,2.4c0.1,-0.1 -0.4,-0.8 -1.3,-1.6c-5.1,-4.5 -2.3,-11.7 5,-12.8c5.4,-0.8 11.4,2.7 13.9,8c0.8,1.7 1,2.5 1,5.3c0,2.8 -0.1,3.5 -1,5.3c-2,4.3 -6.8,7.9 -10.3,7.8c-0.9,-0.1 -3.6,-0.5 -5.9,-0.8Z" />
|
||||
<path
|
||||
android:fillColor="#4081ef"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M665.1,811.2c-3.4,-1.3 -6.4,-4.3 -7.8,-8.1c-1.1,-2.9 -0.9,-7.3 0.5,-10.2c2.6,-5.3 8.7,-8.5 14.4,-7.5c2.9,0.5 4.7,1.9 6,4.3c0.8,1.6 1,2.2 0.8,3.6c-0.3,2.2 -0.9,3.3 -2.7,4.8c-0.8,0.7 -1.4,1.4 -1.3,1.5c0.5,0.5 13.4,-2.7 21.3,-5.4c33.6,-11.3 62.5,-35.1 80.4,-66.1c2.5,-4.4 2.6,-5 0.5,-3.2c-2.8,2.4 -7,1.9 -9.6,-1c-4,-4.6 -0.7,-13.8 6.1,-16.9c2,-0.9 2.7,-1 5.5,-1c2.9,0 3.5,0.1 5.6,1.1c4.4,2.1 7.4,6.4 7.8,11c0.2,2.2 0.1,2.3 -2.2,6.9c-23,45.9 -67,78.1 -117.2,85.9c-5.5,0.9 -6.3,1 -8.1,0.3Z" />
|
||||
<path
|
||||
android:fillColor="#31a452"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578.6,771.5c-4.7,-0.9 -8.7,-2.7 -12.9,-5.9c-10.8,-8.1 -13.5,-22.3 -6.6,-33.7c0.7,-1.2 1.1,-2.2 1,-2.4c-0.2,-0.2 -1.2,-0.6 -2.3,-1.1c-7.6,-3 -13,-10.6 -13.5,-19.1c-0.5,-7.4 3.1,-15 9,-19.4c1,-0.7 2.2,-1.5 2.6,-1.8c0.8,-0.4 68.9,-22.7 69.4,-22.7c0.2,0 0.7,0.7 1.2,1.5c0.5,0.8 1.6,2.3 2.4,3.3c1.2,1.4 1.5,1.9 1.2,2.3c-0.2,0.3 -6.9,9.5 -14.8,20.5c-15.9,21.9 -15.5,21.3 -13.4,23.4c1.3,1.3 2.9,1.4 4.4,0.3c0.6,-0.4 7.5,-9.7 15.5,-20.7c11.2,-15.4 14.6,-19.9 15,-19.7c0.9,0.4 5.5,1.9 6.6,2.1l1,0.2l-0,35.3c-0,39.7 -0,38.8 -2.5,44c-2.6,5.3 -7.2,9.3 -12.7,11.2c-3.7,1.3 -6.8,1.6 -10.2,1c-5.5,-0.9 -9.8,-3.2 -13.7,-7.4l-2.2,-2.4l-0.6,0.9c-3,4.3 -8.6,8.1 -14,9.5c-2.8,0.9 -7.8,1.2 -9.9,0.8Z" />
|
||||
<path
|
||||
android:fillColor="#ffb800"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M710.4,771.5c-5.5,-0.9 -9.9,-3.2 -14.3,-7.6l-3.2,-3.2l-0.7,1c-2.3,3.3 -6.8,6.5 -11.1,7.9c-3.7,1.2 -9.2,1.4 -12.6,0.3c-7.1,-2.1 -12.7,-7.4 -15.2,-14.3l-0.9,-2.6l0,-74.2l1.8,-0.4c1,-0.2 2.7,-0.8 3.9,-1.2c1.1,-0.5 2.1,-0.8 2.2,-0.7c0.1,0.1 6.5,9 14.4,19.9c7.8,10.9 14.7,20.1 15.2,20.5c2.2,1.9 5.4,0.4 5.4,-2.6c0,-1.4 -1,-2.9 -13.8,-20.5c-7.6,-10.5 -14.2,-19.6 -14.7,-20.4l-0.9,-1.3l1.4,-1.7c0.8,-0.9 1.9,-2.5 2.5,-3.4l1,-1.6l34.4,11.2c18.9,6.2 35.1,11.6 35.9,12.1c6.8,4 11.1,11.3 11.1,19.1c0,4.1 -0.5,6.4 -2.4,10.2c-2,4.1 -5.5,7.6 -9.6,9.7c-1.6,0.8 -3.2,1.5 -3.4,1.5c-1,0 -0.9,0.7 0.3,2.6c2.8,4.3 4,8.5 3.9,13.7c0,8.1 -3.7,15.2 -10.6,20.3c-6.5,4.8 -13.4,6.7 -20,5.7Z" />
|
||||
<path
|
||||
android:fillColor="#de7fb3"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M421.4,714.9c-0.5,-0.1 -2.3,-0.4 -3.9,-0.7c-15.6,-2.6 -30.4,-12.6 -38.8,-26.2c-3.5,-5.7 -6.4,-13.2 -7.8,-19.9c-1.2,-6.1 -0.8,-28.1 0.8,-43.1c4.5,-43 19,-84.3 42.2,-120.7c6.5,-10.2 14.9,-21.5 18.2,-24.6c17.8,-16.6 43.1,-20.5 64.8,-10c4.3,2.1 8.8,5.1 12.7,8.6c2.8,2.4 5.8,6.1 20.9,25.5c9.7,12.5 17.8,22.8 17.9,23c0.2,0.2 -0.9,0.4 -3.2,0.4c-2.5,0 -4.1,0.2 -5.7,0.7c-2.1,0.7 -2.6,1.1 -7.9,6.3c-8.2,8.1 -14.4,15.3 -20.3,23.9c-15.5,22.2 -25.4,47.7 -28.8,74.8c-2.2,16.9 -1.6,37.5 1.6,52.3c0.3,1.4 0.5,2.8 0.4,3c-0.1,0.2 0.2,1.3 0.8,2.4c1.1,2.4 4.3,5.7 6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2 -13.1,3.8 -27.6,8c-16.4,4.7 -27.7,7.8 -29.8,8.1c-3.1,0.4 -11.1,0.6 -13.3,0.2Z" />
|
||||
<path
|
||||
android:fillColor="#ffb800"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M862.2,714.7c-2.1,-0.3 -33.8,-9.1 -56.5,-15.8l-2.5,-0.7l1.6,-0.8c3.4,-1.7 7.2,-6.6 7.3,-9.6c0,-0.7 0.4,-3.3 0.8,-5.8c3.9,-22.7 3.1,-46.1 -2.5,-68.4c-6.4,-25.5 -18.6,-49.2 -35.8,-69.1c-4.6,-5.3 -14.8,-15.4 -16.4,-16.1c-2.4,-1.1 -5.1,-1.6 -8,-1.4l-2.7,0.2l1.2,-1.5c0.7,-0.8 8.5,-10.8 17.5,-22.3c8.9,-11.5 17.2,-21.8 18.5,-23.1c2.6,-2.7 7,-6.2 10.3,-8.2c19.3,-11.6 43,-11.1 61.6,1.2c5.4,3.6 8.2,6.2 12.3,11.7c26.4,34.5 44,73.7 52.3,116.2c3.4,17.6 4.9,33.3 5,52.4c0,13 -0.2,14.8 -2.5,21.8c-8.4,26.2 -34.5,42.8 -61.5,39.3Z" />
|
||||
<path
|
||||
android:fillColor="#e64132"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M501.4,691.5c-2,-0.5 -4.6,-1.9 -6,-3.3c-2.5,-2.4 -3.1,-3.5 -3.7,-7.3c-4.4,-27.3 -2.2,-54 6.7,-79.3c5.3,-15.1 13.5,-30.5 23,-43.1c5.8,-7.8 16.6,-19.5 19,-20.7c4.7,-2.4 11.3,-1.2 15.2,2.7c5.4,5.4 5.2,13.9 -0.3,19.1c-4.3,4 -9.4,4.4 -12.6,0.9c-1.7,-1.9 -2.2,-3.9 -1.7,-6.4c0.2,-1.1 0.3,-2 0.2,-2.2c-0.3,-0.3 -3.6,3.3 -8.3,9.1c-17.6,21.8 -28.5,48 -31.9,76.5c-1.1,9.3 -1,26.4 0.1,34.6c0.3,1.8 0.8,1.9 1.4,0.1c0.9,-2.6 4,-4.7 6.8,-4.7c3,-0 5.9,2.2 7.5,5.7c0.6,1.3 0.8,2.3 0.8,5.2c-0,3.3 -0.1,3.8 -1.1,5.7c-1.4,2.7 -4.6,5.7 -7.1,6.6c-2.5,0.9 -6.1,1.2 -8,0.8Z" />
|
||||
<path
|
||||
android:fillColor="#31a452"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M790.1,691.5c-3.7,-0.6 -7.7,-3.6 -9.4,-7.1c-3.8,-7.5 0.1,-16.9 6.9,-16.9c3.1,0 5.8,2 6.9,5.2c0.4,1.2 0.5,1.3 0.7,0.7c1.3,-3.7 1.7,-26.4 0.6,-35.7c-3.6,-29.6 -14.5,-55.3 -33,-77.9c-5.5,-6.7 -8.4,-9.4 -7.1,-6.6c0.7,1.4 0.5,4.3 -0.3,5.9c-0.9,1.7 -3.2,3.5 -5,3.8c-3.2,0.6 -7.9,-1.6 -10.2,-4.8c-6.5,-8.8 -0.5,-21.2 10.4,-21.4c4.6,-0.1 5.2,0.3 11.2,6.4c12.1,12.3 21.1,24.9 28.8,40.3c13.2,26.3 18.6,54.9 16.1,84.5c-0.5,5.6 -2,15.7 -2.6,17.1c-1.3,2.8 -4.8,5.5 -8.4,6.5c-2.3,0.4 -3.1,0.4 -5.6,0Z" />
|
||||
<path
|
||||
android:fillColor="#4081ef"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M545.7,680.2c-6,-1.3 -12.2,-6.2 -14.9,-11.7c-3.4,-7 -3.1,-15.1 0.9,-21.6c0.7,-1.2 1.2,-2.3 1.1,-2.4c-0.1,-0.1 -1.1,-0.6 -2.1,-1c-3.9,-1.5 -8.1,-4.8 -10.7,-8.3c-4.6,-6.2 -6.1,-14.6 -3.9,-22.1c2.9,-10.3 9.4,-16.8 19.1,-19.3c2.8,-0.7 9,-0.8 11.7,-0c1.1,0.3 2.2,0.5 2.4,0.5c0.2,-0 0.3,-0.7 0.3,-1.5c0,-2.9 0.8,-5.8 2.4,-9.2c5.2,-10.8 18.1,-15.5 29,-10.5c2.7,1.2 6.2,3.8 7.8,5.8c0.7,0.8 10.3,14 21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1 -1.9,2.6 -2.5,3.5c-0.6,1 -1.2,1.7 -1.5,1.6c-4.5,-1.7 -46.7,-15 -47.7,-15c-1.9,0 -3.1,1.3 -3.1,3.2c0,1 0.2,1.7 0.8,2.3c0.6,0.6 7.8,3.1 24.5,8.5l23.7,7.7l-0.2,8.6l-32.6,10.5c-18,5.9 -33.9,10.9 -35.2,11.2c-3.1,0.7 -6.6,0.7 -9.6,0.1Z" />
|
||||
<path
|
||||
android:fillColor="#e64132"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M740,679.8c-1.8,-0.5 -17.5,-5.6 -35,-11.3l-31.8,-10.4l1,-4.3l0,-4.3l22.6,-7.7c15,-4.9 24,-8 24.6,-8.5c0.7,-0.6 0.9,-1.1 0.9,-2.2c-0,-2 -1.2,-3.3 -3.1,-3.3c-0.9,-0 -10.5,2.9 -24.7,7.5c-12.8,4.1 -23.4,7.5 -23.6,7.5c-0.1,-0 -0.7,-0.8 -1.3,-1.9c-0.6,-1 -1.6,-2.5 -2.2,-3.2c-0.7,-0.7 -1.2,-1.5 -1.2,-1.6c0,-0.2 9.6,-13.5 21.4,-29.6c18.9,-26 21.6,-29.6 23.6,-31.1c5.7,-4.4 13.1,-5.8 19.7,-3.9c9,2.7 16.1,11.6 16.1,20.3c0,2.3 -0.1,2.3 3.1,1.5c4.7,-1.1 11.5,-0.5 16,1.5c4.6,2 9,6 11.5,10.2c2.1,3.6 3.9,9.4 4.2,13.2c0.3,5.2 -1.1,10.7 -4,15.3c-2.6,4.1 -7.8,8.3 -12.1,9.8c-0.9,0.3 -1.7,0.8 -1.7,1c0,0.2 0.4,1 0.9,1.7c2.4,3.6 3.6,7.7 3.5,12.7c0,5.8 -2.1,10.7 -6.4,15.1c-4,4.1 -8.9,6.3 -14.9,6.5c-3.3,0.4 -4.3,0.3 -7.1,-0.5Z" />
|
||||
<path
|
||||
android:fillColor="#f2f5fb"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M643.7,671.9c-6.1,-1.6 -11.4,-6.8 -13.2,-12.9c-0.7,-2.4 -0.7,-7.5 0,-9.9c1.7,-5.8 6.6,-10.8 12.3,-12.5c2.7,-0.8 7.2,-0.9 10,-0.2c6.2,1.6 11.6,7.1 13.2,13.3c1.6,6 -0.3,12.6 -5,17.3c-4.6,4.6 -11.3,6.5 -17.3,4.9Z" />
|
||||
<path
|
||||
android:fillColor="#de7fb3"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M615.8,602.8c-13.3,-18.3 -21.2,-29.6 -22,-31.1c-1.4,-3 -1.9,-5.5 -1.9,-9.4c0,-14.1 13.1,-24.4 27.1,-21.4c1.4,0.3 2.6,0.5 2.7,0.5c0.1,0 0.3,-1.3 0.4,-2.8c0.8,-10.7 8.4,-19.6 18.9,-22.4c3.9,-1 10.6,-1 14.5,-0c8.9,2.3 15.9,9.3 18.2,18.2c0.4,1.5 0.7,3.7 0.7,4.9c-0,1.2 0.1,2.1 0.3,2.1c0.2,-0 1.5,-0.3 3,-0.6c7.4,-1.6 15.2,0.7 20.5,6c4.3,4.3 6.6,9.6 6.6,15.6c-0,4 -0.6,6.5 -2.4,10c-0.6,1.2 -10.4,15 -21.7,30.7c-17.8,24.5 -20.8,28.5 -21.4,28.3c-0.4,-0.1 -1.9,-0.6 -3.4,-1.1c-1.5,-0.5 -2.9,-0.9 -3.3,-0.9c-0.7,-0 -0.7,-0.8 -0.3,-25.5l-0,-25.5l-1.4,-0.9c-1,-1.1 -2.5,-1.5 -3.8,-0.9c-2,0.8 -2,-0.5 -1.8,27.2l-0,25.8l-1.2,-0c-0.5,-0.2 -2.4,0.3 -4,0.9c-1.6,0.6 -3.1,1.1 -3.2,1.1c-0.2,-0.1 -9.6,-13 -21.1,-28.8Z" />
|
||||
<path
|
||||
android:fillColor="#ffb800"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578.4,537.8c-4.1,-0.9 -7.7,-3.6 -9.6,-7.4c-1.4,-2.8 -1.7,-7.3 -0.5,-10.3c1.7,-4.5 3.9,-6.1 15.6,-11.2c15.8,-7 31.4,-11.1 49.2,-12.9c7.3,-0.8 23.2,-0.8 30.6,0c17.4,1.8 33.3,6 49.1,13c7.3,3.2 12.5,6.1 13.6,7.5c4.3,5.6 3.8,12.7 -1.1,17.6c-5.1,5.1 -12.9,5.4 -18.1,0.7c-2,-1.8 -3,-3.5 -3.4,-5.6c-0.7,-4 2.9,-8.1 7.3,-8.2c1.4,0 1.5,-0.1 1.1,-0.5c-0.3,-0.3 -2.2,-1.2 -4.3,-2.1c-33.2,-14.5 -70.5,-16.4 -105,-5.4c-7.5,2.4 -19,7.2 -18.6,7.7c0.1,0.2 0.8,0.3 1.6,0.3c5.6,0 9.1,6.2 6.1,10.8c-2.9,4.5 -8.6,7.1 -13.6,6Z" />
|
||||
<path
|
||||
android:fillColor="#e64132"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M542.2,496.4c-8.9,-13.1 -16.8,-25.1 -17.5,-26.6c-1.6,-3.3 -3.6,-9.2 -4.4,-13c-2.6,-12.5 -0.9,-25.8 5,-37.5c4.2,-8.3 11.2,-16.3 18.6,-21.3c5,-3.4 6.1,-3.9 12.8,-6.3c23.1,-8.2 47.2,-13.1 73.4,-15c7.5,-0.6 28.5,-0.6 36.3,-0c25.5,1.8 50.6,6.9 73,14.8c6.4,2.2 8.2,3.1 13.1,6.5c9.8,6.6 18.1,17.5 22,29.2c2.2,6.5 2.7,10 2.7,17.9c0,7.9 -0.5,11.3 -2.7,17.9c-2.3,6.8 -3.7,9.1 -20.3,33.6l-16.1,23.8l-0.4,-2.2c-0.2,-1.2 -0.9,-3 -1.4,-4c-1,-1.8 -4.4,-5.6 -4.7,-5.2c-0.1,0.1 -1.2,-0.4 -2.4,-1.1c-9.1,-5.2 -21.9,-10.5 -33.2,-13.9c-37,-11 -77.2,-8.8 -113,6.1c-4.9,2.1 -17.7,8.4 -19.2,9.5c-2.2,1.6 -5.1,6.8 -5.1,9c0,0.4 -0.1,1 -0.3,1.2c0.1,0.2 -6.2,-8.8 -16.2,-23.4Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,70 @@
|
||||
<vector android:height="200dp"
|
||||
android:viewportHeight="1300"
|
||||
android:viewportWidth="1300"
|
||||
android:width="200dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578,922.6c-2.2,-0.2 -5.5,-1 -9.7,-2.2c-52.4,-15.7 -99,-46.5 -133.8,-88.5c-8.8,-10.7 -17.2,-22.4 -19.4,-27.5c-8.1,-18.1 -6.3,-38.7 4.8,-55.4c5,-7.5 13.2,-15 20.5,-18.7c1.2,-0.6 54.1,-20 55.8,-20.4c0.5,-0.1 0.5,0.2 -0.3,2.1c-0.7,1.7 -1,3.1 -1.1,5.5l-0.1,3.2l2.8,5.8c8.7,17.9 19.2,32.7 33.2,46.4c6.3,6.2 7.8,7.6 13.8,12.3c22.7,18.1 52,30.7 79.9,34.3c2.5,0.3 5,0.8 5.7,1c2.8,0.9 7.7,-0.8 11,-3.7l1.8,-1.6l-0.2,4.8c-0.1,2.7 -0.6,15.4 -1,28.3c-0.6,20.3 -0.8,24 -1.5,27.5c-3.9,20.7 -18.6,37.5 -38.4,44.1c-4.6,1.5 -8,2.2 -13.1,2.7c-4.6,0.5 -5.9,0.4 -10.7,0Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M707.3,922.4c-4,-0.4 -9.4,-1.6 -13.2,-2.9c-3.4,-1.2 -10,-4.4 -12.5,-6.1c-10.9,-7.4 -19,-17.9 -23.1,-30c-2.2,-6.7 -2.3,-7.5 -3.3,-36.9c-0.5,-14.9 -0.9,-27.9 -0.9,-28.9l-0,-1.9l2.3,1.8c2.6,2 6.6,3.4 8.5,3.1c0.6,-0.1 3,-0.5 5.3,-0.8c37.7,-5.3 71.2,-22.2 97.4,-49.1c12.2,-12.5 21.4,-25.5 29.9,-42.4l3.5,-7l0,-3.6c0,-3.1 -0.1,-3.8 -1,-5.7c-0.5,-1.2 -0.9,-2.1 -0.9,-2.2c0.2,-0.2 55.3,20.1 56.9,20.9c2.6,1.3 6.6,4.1 9.9,7c9.2,7.7 16.1,19.4 18.8,31.8c0.7,3.1 0.8,4.8 0.8,11.3c0,8.6 -0.5,11.7 -2.9,18.7c-1.7,5 -2.9,7.2 -7.1,13.1c-7.6,11 -15.3,20.5 -25.2,31.2c-32.8,35.4 -76.5,62.5 -123.4,76.3c-8,2.5 -12.4,3 -19.8,2.3Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M623.1,811c-25.9,-4.2 -50.7,-14.9 -71.7,-31c-5.2,-4 -8.7,-7.1 -14.1,-12.4c-12.7,-12.5 -21.9,-24.9 -30.5,-41.4c-2.3,-4.4 -2.4,-4.7 -2.4,-7.1c0,-8.8 8.5,-15.2 16.9,-12.7c5.6,1.7 9.6,6.8 9.7,12.2c0,2.6 -0.8,4.6 -2.6,6.2c-1.2,1.1 -3.2,1.9 -4.6,1.9c-1.2,0 -3.3,-0.8 -4.3,-1.6c-2.1,-1.8 -2,-1 0.4,3.2c19.3,33.8 52.3,59.1 90,69.1c5.7,1.5 11.5,2.7 11.8,2.4c0.1,-0.1 -0.4,-0.8 -1.3,-1.6c-5.1,-4.5 -2.3,-11.7 5,-12.8c5.4,-0.8 11.4,2.7 13.9,8c0.8,1.7 1,2.5 1,5.3c0,2.8 -0.1,3.5 -1,5.3c-2,4.3 -6.8,7.9 -10.3,7.8c-0.9,-0.1 -3.6,-0.5 -5.9,-0.8Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M665.1,811.2c-3.4,-1.3 -6.4,-4.3 -7.8,-8.1c-1.1,-2.9 -0.9,-7.3 0.5,-10.2c2.6,-5.3 8.7,-8.5 14.4,-7.5c2.9,0.5 4.7,1.9 6,4.3c0.8,1.6 1,2.2 0.8,3.6c-0.3,2.2 -0.9,3.3 -2.7,4.8c-0.8,0.7 -1.4,1.4 -1.3,1.5c0.5,0.5 13.4,-2.7 21.3,-5.4c33.6,-11.3 62.5,-35.1 80.4,-66.1c2.5,-4.4 2.6,-5 0.5,-3.2c-2.8,2.4 -7,1.9 -9.6,-1c-4,-4.6 -0.7,-13.8 6.1,-16.9c2,-0.9 2.7,-1 5.5,-1c2.9,0 3.5,0.1 5.6,1.1c4.4,2.1 7.4,6.4 7.8,11c0.2,2.2 0.1,2.3 -2.2,6.9c-23,45.9 -67,78.1 -117.2,85.9c-5.5,0.9 -6.3,1 -8.1,0.3Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578.6,771.5c-4.7,-0.9 -8.7,-2.7 -12.9,-5.9c-10.8,-8.1 -13.5,-22.3 -6.6,-33.7c0.7,-1.2 1.1,-2.2 1,-2.4c-0.2,-0.2 -1.2,-0.6 -2.3,-1.1c-7.6,-3 -13,-10.6 -13.5,-19.1c-0.5,-7.4 3.1,-15 9,-19.4c1,-0.7 2.2,-1.5 2.6,-1.8c0.8,-0.4 68.9,-22.7 69.4,-22.7c0.2,0 0.7,0.7 1.2,1.5c0.5,0.8 1.6,2.3 2.4,3.3c1.2,1.4 1.5,1.9 1.2,2.3c-0.2,0.3 -6.9,9.5 -14.8,20.5c-15.9,21.9 -15.5,21.3 -13.4,23.4c1.3,1.3 2.9,1.4 4.4,0.3c0.6,-0.4 7.5,-9.7 15.5,-20.7c11.2,-15.4 14.6,-19.9 15,-19.7c0.9,0.4 5.5,1.9 6.6,2.1l1,0.2l-0,35.3c-0,39.7 -0,38.8 -2.5,44c-2.6,5.3 -7.2,9.3 -12.7,11.2c-3.7,1.3 -6.8,1.6 -10.2,1c-5.5,-0.9 -9.8,-3.2 -13.7,-7.4l-2.2,-2.4l-0.6,0.9c-3,4.3 -8.6,8.1 -14,9.5c-2.8,0.9 -7.8,1.2 -9.9,0.8Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M710.4,771.5c-5.5,-0.9 -9.9,-3.2 -14.3,-7.6l-3.2,-3.2l-0.7,1c-2.3,3.3 -6.8,6.5 -11.1,7.9c-3.7,1.2 -9.2,1.4 -12.6,0.3c-7.1,-2.1 -12.7,-7.4 -15.2,-14.3l-0.9,-2.6l0,-74.2l1.8,-0.4c1,-0.2 2.7,-0.8 3.9,-1.2c1.1,-0.5 2.1,-0.8 2.2,-0.7c0.1,0.1 6.5,9 14.4,19.9c7.8,10.9 14.7,20.1 15.2,20.5c2.2,1.9 5.4,0.4 5.4,-2.6c0,-1.4 -1,-2.9 -13.8,-20.5c-7.6,-10.5 -14.2,-19.6 -14.7,-20.4l-0.9,-1.3l1.4,-1.7c0.8,-0.9 1.9,-2.5 2.5,-3.4l1,-1.6l34.4,11.2c18.9,6.2 35.1,11.6 35.9,12.1c6.8,4 11.1,11.3 11.1,19.1c0,4.1 -0.5,6.4 -2.4,10.2c-2,4.1 -5.5,7.6 -9.6,9.7c-1.6,0.8 -3.2,1.5 -3.4,1.5c-1,0 -0.9,0.7 0.3,2.6c2.8,4.3 4,8.5 3.9,13.7c0,8.1 -3.7,15.2 -10.6,20.3c-6.5,4.8 -13.4,6.7 -20,5.7Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M421.4,714.9c-0.5,-0.1 -2.3,-0.4 -3.9,-0.7c-15.6,-2.6 -30.4,-12.6 -38.8,-26.2c-3.5,-5.7 -6.4,-13.2 -7.8,-19.9c-1.2,-6.1 -0.8,-28.1 0.8,-43.1c4.5,-43 19,-84.3 42.2,-120.7c6.5,-10.2 14.9,-21.5 18.2,-24.6c17.8,-16.6 43.1,-20.5 64.8,-10c4.3,2.1 8.8,5.1 12.7,8.6c2.8,2.4 5.8,6.1 20.9,25.5c9.7,12.5 17.8,22.8 17.9,23c0.2,0.2 -0.9,0.4 -3.2,0.4c-2.5,0 -4.1,0.2 -5.7,0.7c-2.1,0.7 -2.6,1.1 -7.9,6.3c-8.2,8.1 -14.4,15.3 -20.3,23.9c-15.5,22.2 -25.4,47.7 -28.8,74.8c-2.2,16.9 -1.6,37.5 1.6,52.3c0.3,1.4 0.5,2.8 0.4,3c-0.1,0.2 0.2,1.3 0.8,2.4c1.1,2.4 4.3,5.7 6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2 -13.1,3.8 -27.6,8c-16.4,4.7 -27.7,7.8 -29.8,8.1c-3.1,0.4 -11.1,0.6 -13.3,0.2Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M862.2,714.7c-2.1,-0.3 -33.8,-9.1 -56.5,-15.8l-2.5,-0.7l1.6,-0.8c3.4,-1.7 7.2,-6.6 7.3,-9.6c0,-0.7 0.4,-3.3 0.8,-5.8c3.9,-22.7 3.1,-46.1 -2.5,-68.4c-6.4,-25.5 -18.6,-49.2 -35.8,-69.1c-4.6,-5.3 -14.8,-15.4 -16.4,-16.1c-2.4,-1.1 -5.1,-1.6 -8,-1.4l-2.7,0.2l1.2,-1.5c0.7,-0.8 8.5,-10.8 17.5,-22.3c8.9,-11.5 17.2,-21.8 18.5,-23.1c2.6,-2.7 7,-6.2 10.3,-8.2c19.3,-11.6 43,-11.1 61.6,1.2c5.4,3.6 8.2,6.2 12.3,11.7c26.4,34.5 44,73.7 52.3,116.2c3.4,17.6 4.9,33.3 5,52.4c0,13 -0.2,14.8 -2.5,21.8c-8.4,26.2 -34.5,42.8 -61.5,39.3Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M501.4,691.5c-2,-0.5 -4.6,-1.9 -6,-3.3c-2.5,-2.4 -3.1,-3.5 -3.7,-7.3c-4.4,-27.3 -2.2,-54 6.7,-79.3c5.3,-15.1 13.5,-30.5 23,-43.1c5.8,-7.8 16.6,-19.5 19,-20.7c4.7,-2.4 11.3,-1.2 15.2,2.7c5.4,5.4 5.2,13.9 -0.3,19.1c-4.3,4 -9.4,4.4 -12.6,0.9c-1.7,-1.9 -2.2,-3.9 -1.7,-6.4c0.2,-1.1 0.3,-2 0.2,-2.2c-0.3,-0.3 -3.6,3.3 -8.3,9.1c-17.6,21.8 -28.5,48 -31.9,76.5c-1.1,9.3 -1,26.4 0.1,34.6c0.3,1.8 0.8,1.9 1.4,0.1c0.9,-2.6 4,-4.7 6.8,-4.7c3,-0 5.9,2.2 7.5,5.7c0.6,1.3 0.8,2.3 0.8,5.2c-0,3.3 -0.1,3.8 -1.1,5.7c-1.4,2.7 -4.6,5.7 -7.1,6.6c-2.5,0.9 -6.1,1.2 -8,0.8Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M790.1,691.5c-3.7,-0.6 -7.7,-3.6 -9.4,-7.1c-3.8,-7.5 0.1,-16.9 6.9,-16.9c3.1,0 5.8,2 6.9,5.2c0.4,1.2 0.5,1.3 0.7,0.7c1.3,-3.7 1.7,-26.4 0.6,-35.7c-3.6,-29.6 -14.5,-55.3 -33,-77.9c-5.5,-6.7 -8.4,-9.4 -7.1,-6.6c0.7,1.4 0.5,4.3 -0.3,5.9c-0.9,1.7 -3.2,3.5 -5,3.8c-3.2,0.6 -7.9,-1.6 -10.2,-4.8c-6.5,-8.8 -0.5,-21.2 10.4,-21.4c4.6,-0.1 5.2,0.3 11.2,6.4c12.1,12.3 21.1,24.9 28.8,40.3c13.2,26.3 18.6,54.9 16.1,84.5c-0.5,5.6 -2,15.7 -2.6,17.1c-1.3,2.8 -4.8,5.5 -8.4,6.5c-2.3,0.4 -3.1,0.4 -5.6,0Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M545.7,680.2c-6,-1.3 -12.2,-6.2 -14.9,-11.7c-3.4,-7 -3.1,-15.1 0.9,-21.6c0.7,-1.2 1.2,-2.3 1.1,-2.4c-0.1,-0.1 -1.1,-0.6 -2.1,-1c-3.9,-1.5 -8.1,-4.8 -10.7,-8.3c-4.6,-6.2 -6.1,-14.6 -3.9,-22.1c2.9,-10.3 9.4,-16.8 19.1,-19.3c2.8,-0.7 9,-0.8 11.7,-0c1.1,0.3 2.2,0.5 2.4,0.5c0.2,-0 0.3,-0.7 0.3,-1.5c0,-2.9 0.8,-5.8 2.4,-9.2c5.2,-10.8 18.1,-15.5 29,-10.5c2.7,1.2 6.2,3.8 7.8,5.8c0.7,0.8 10.3,14 21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1 -1.9,2.6 -2.5,3.5c-0.6,1 -1.2,1.7 -1.5,1.6c-4.5,-1.7 -46.7,-15 -47.7,-15c-1.9,0 -3.1,1.3 -3.1,3.2c0,1 0.2,1.7 0.8,2.3c0.6,0.6 7.8,3.1 24.5,8.5l23.7,7.7l-0.2,8.6l-32.6,10.5c-18,5.9 -33.9,10.9 -35.2,11.2c-3.1,0.7 -6.6,0.7 -9.6,0.1Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M740,679.8c-1.8,-0.5 -17.5,-5.6 -35,-11.3l-31.8,-10.4l1,-4.3l0,-4.3l22.6,-7.7c15,-4.9 24,-8 24.6,-8.5c0.7,-0.6 0.9,-1.1 0.9,-2.2c-0,-2 -1.2,-3.3 -3.1,-3.3c-0.9,-0 -10.5,2.9 -24.7,7.5c-12.8,4.1 -23.4,7.5 -23.6,7.5c-0.1,-0 -0.7,-0.8 -1.3,-1.9c-0.6,-1 -1.6,-2.5 -2.2,-3.2c-0.7,-0.7 -1.2,-1.5 -1.2,-1.6c0,-0.2 9.6,-13.5 21.4,-29.6c18.9,-26 21.6,-29.6 23.6,-31.1c5.7,-4.4 13.1,-5.8 19.7,-3.9c9,2.7 16.1,11.6 16.1,20.3c0,2.3 -0.1,2.3 3.1,1.5c4.7,-1.1 11.5,-0.5 16,1.5c4.6,2 9,6 11.5,10.2c2.1,3.6 3.9,9.4 4.2,13.2c0.3,5.2 -1.1,10.7 -4,15.3c-2.6,4.1 -7.8,8.3 -12.1,9.8c-0.9,0.3 -1.7,0.8 -1.7,1c0,0.2 0.4,1 0.9,1.7c2.4,3.6 3.6,7.7 3.5,12.7c0,5.8 -2.1,10.7 -6.4,15.1c-4,4.1 -8.9,6.3 -14.9,6.5c-3.3,0.4 -4.3,0.3 -7.1,-0.5Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M643.7,671.9c-6.1,-1.6 -11.4,-6.8 -13.2,-12.9c-0.7,-2.4 -0.7,-7.5 0,-9.9c1.7,-5.8 6.6,-10.8 12.3,-12.5c2.7,-0.8 7.2,-0.9 10,-0.2c6.2,1.6 11.6,7.1 13.2,13.3c1.6,6 -0.3,12.6 -5,17.3c-4.6,4.6 -11.3,6.5 -17.3,4.9Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M615.8,602.8c-13.3,-18.3 -21.2,-29.6 -22,-31.1c-1.4,-3 -1.9,-5.5 -1.9,-9.4c0,-14.1 13.1,-24.4 27.1,-21.4c1.4,0.3 2.6,0.5 2.7,0.5c0.1,0 0.3,-1.3 0.4,-2.8c0.8,-10.7 8.4,-19.6 18.9,-22.4c3.9,-1 10.6,-1 14.5,-0c8.9,2.3 15.9,9.3 18.2,18.2c0.4,1.5 0.7,3.7 0.7,4.9c-0,1.2 0.1,2.1 0.3,2.1c0.2,-0 1.5,-0.3 3,-0.6c7.4,-1.6 15.2,0.7 20.5,6c4.3,4.3 6.6,9.6 6.6,15.6c-0,4 -0.6,6.5 -2.4,10c-0.6,1.2 -10.4,15 -21.7,30.7c-17.8,24.5 -20.8,28.5 -21.4,28.3c-0.4,-0.1 -1.9,-0.6 -3.4,-1.1c-1.5,-0.5 -2.9,-0.9 -3.3,-0.9c-0.7,-0 -0.7,-0.8 -0.3,-25.5l-0,-25.5l-1.4,-0.9c-1,-1.1 -2.5,-1.5 -3.8,-0.9c-2,0.8 -2,-0.5 -1.8,27.2l-0,25.8l-1.2,-0c-0.5,-0.2 -2.4,0.3 -4,0.9c-1.6,0.6 -3.1,1.1 -3.2,1.1c-0.2,-0.1 -9.6,-13 -21.1,-28.8Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578.4,537.8c-4.1,-0.9 -7.7,-3.6 -9.6,-7.4c-1.4,-2.8 -1.7,-7.3 -0.5,-10.3c1.7,-4.5 3.9,-6.1 15.6,-11.2c15.8,-7 31.4,-11.1 49.2,-12.9c7.3,-0.8 23.2,-0.8 30.6,0c17.4,1.8 33.3,6 49.1,13c7.3,3.2 12.5,6.1 13.6,7.5c4.3,5.6 3.8,12.7 -1.1,17.6c-5.1,5.1 -12.9,5.4 -18.1,0.7c-2,-1.8 -3,-3.5 -3.4,-5.6c-0.7,-4 2.9,-8.1 7.3,-8.2c1.4,0 1.5,-0.1 1.1,-0.5c-0.3,-0.3 -2.2,-1.2 -4.3,-2.1c-33.2,-14.5 -70.5,-16.4 -105,-5.4c-7.5,2.4 -19,7.2 -18.6,7.7c0.1,0.2 0.8,0.3 1.6,0.3c5.6,0 9.1,6.2 6.1,10.8c-2.9,4.5 -8.6,7.1 -13.6,6Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M542.2,496.4c-8.9,-13.1 -16.8,-25.1 -17.5,-26.6c-1.6,-3.3 -3.6,-9.2 -4.4,-13c-2.6,-12.5 -0.9,-25.8 5,-37.5c4.2,-8.3 11.2,-16.3 18.6,-21.3c5,-3.4 6.1,-3.9 12.8,-6.3c23.1,-8.2 47.2,-13.1 73.4,-15c7.5,-0.6 28.5,-0.6 36.3,-0c25.5,1.8 50.6,6.9 73,14.8c6.4,2.2 8.2,3.1 13.1,6.5c9.8,6.6 18.1,17.5 22,29.2c2.2,6.5 2.7,10 2.7,17.9c0,7.9 -0.5,11.3 -2.7,17.9c-2.3,6.8 -3.7,9.1 -20.3,33.6l-16.1,23.8l-0.4,-2.2c-0.2,-1.2 -0.9,-3 -1.4,-4c-1,-1.8 -4.4,-5.6 -4.7,-5.2c-0.1,0.1 -1.2,-0.4 -2.4,-1.1c-9.1,-5.2 -21.9,-10.5 -33.2,-13.9c-37,-11 -77.2,-8.8 -113,6.1c-4.9,2.1 -17.7,8.4 -19.2,9.5c-2.2,1.6 -5.1,6.8 -5.1,9c0,0.4 -0.1,1 -0.3,1.2c0.1,0.2 -6.2,-8.8 -16.2,-23.4Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
4
mobile/android/app/src/main/res/values/colors.xml
Normal file
4
mobile/android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 66,
|
||||
"android.injected.version.name" => "1.43.0",
|
||||
"android.injected.version.code" => 67,
|
||||
"android.injected.version.name" => "1.44.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
* Fix crash at first start related to uninitialized hive key
|
||||
* Fix invalid creation time on local asset show 1970 as year
|
||||
* Fix Home page app bar icons don't conform to theme change
|
||||
* Fix endless 'Building timeline' loop after changing the number of assets per row
|
||||
* Show current upload asset
|
||||
* Add to album from asset detail view
|
||||
* Add multi selected assets to album
|
||||
@@ -13,6 +13,8 @@ PODS:
|
||||
- FMDB/standard (2.7.5)
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_ios (0.0.1):
|
||||
@@ -42,6 +44,7 @@ DEPENDENCIES:
|
||||
- flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
|
||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
|
||||
@@ -69,6 +72,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_ios:
|
||||
@@ -95,6 +100,7 @@ SPEC CHECKSUMS:
|
||||
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
|
||||
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 79;
|
||||
CURRENT_PROJECT_VERSION = 82;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -495,7 +495,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 79;
|
||||
CURRENT_PROJECT_VERSION = 82;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -522,7 +522,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 79;
|
||||
CURRENT_PROJECT_VERSION = 82;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.42.0</string>
|
||||
<string>1.43.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>79</string>
|
||||
<string>82</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true />
|
||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.42.0"
|
||||
version_number: "1.44.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000301">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000396">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.73906">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.478301">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="5.857767">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.846552">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.648708">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.367554">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="88.88212">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="75.618447">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="162.957763">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="47.502114">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
||||
SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService)
|
||||
SharedAlbumNotifier(this._albumService, this._sharedAlbumCacheService)
|
||||
: super([]);
|
||||
|
||||
final AlbumService _sharedAlbumService;
|
||||
final AlbumService _albumService;
|
||||
final SharedAlbumCacheService _sharedAlbumCacheService;
|
||||
|
||||
_cacheState() {
|
||||
@@ -22,7 +22,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
||||
List<String> sharedUserIds,
|
||||
) async {
|
||||
try {
|
||||
var newAlbum = await _sharedAlbumService.createAlbum(
|
||||
var newAlbum = await _albumService.createAlbum(
|
||||
albumName,
|
||||
assets,
|
||||
sharedUserIds,
|
||||
@@ -47,7 +47,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
||||
}
|
||||
|
||||
List<AlbumResponseDto>? sharedAlbums =
|
||||
await _sharedAlbumService.getAlbums(isShared: true);
|
||||
await _albumService.getAlbums(isShared: true);
|
||||
|
||||
if (sharedAlbums != null) {
|
||||
state = sharedAlbums;
|
||||
@@ -61,7 +61,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
||||
}
|
||||
|
||||
Future<bool> leaveAlbum(String albumId) async {
|
||||
var res = await _sharedAlbumService.leaveAlbum(albumId);
|
||||
var res = await _albumService.leaveAlbum(albumId);
|
||||
|
||||
if (res) {
|
||||
state = state.where((album) => album.id != albumId).toList();
|
||||
@@ -76,7 +76,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
|
||||
String albumId,
|
||||
List<String> assetIds,
|
||||
) async {
|
||||
var res = await _sharedAlbumService.removeAssetFromAlbum(albumId, assetIds);
|
||||
var res = await _albumService.removeAssetFromAlbum(albumId, assetIds);
|
||||
|
||||
if (res) {
|
||||
return true;
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:immich_mobile/modules/album/providers/asset_selection.provider.d
|
||||
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/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
@@ -15,7 +14,6 @@ import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
|
||||
/// The asset to add to an album
|
||||
final List<Asset> assets;
|
||||
|
||||
@@ -38,7 +36,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
[],
|
||||
);
|
||||
|
||||
void addToAlbum(AlbumResponseDto album) async {
|
||||
@@ -46,7 +44,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
assets,
|
||||
album.id,
|
||||
);
|
||||
|
||||
|
||||
if (result != null) {
|
||||
if (result.alreadyInAlbum.isNotEmpty) {
|
||||
ImmichToast.show(
|
||||
@@ -59,7 +57,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
msg: 'Added to ${album.albumName}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
@@ -67,7 +65,6 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
|
||||
return Card(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
@@ -83,6 +80,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
const Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomDraggingHandle(),
|
||||
@@ -91,15 +89,20 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Add to album',
|
||||
Text(
|
||||
'Add to album',
|
||||
style: Theme.of(context).textTheme.headline2,
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Create new album'),
|
||||
onPressed: () {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
ref.watch(assetSelectionProvider.notifier).addNewAssets(assets);
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.removeAll();
|
||||
ref
|
||||
.watch(assetSelectionProvider.notifier)
|
||||
.addNewAssets(assets);
|
||||
AutoRouter.of(context).push(
|
||||
CreateAlbumRoute(
|
||||
isSharedAlbum: false,
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.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/modules/album/ui/album_thumbnail_listtile.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class AddToAlbumList extends HookConsumerWidget {
|
||||
|
||||
/// The asset to add to an album
|
||||
final List<Asset> assets;
|
||||
|
||||
const AddToAlbumList({
|
||||
Key? key,
|
||||
required this.assets,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(albumProvider);
|
||||
final albumService = ref.watch(albumServiceProvider);
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
// Fetch album updates, e.g., cover image
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
void addToAlbum(AlbumResponseDto album) async {
|
||||
final result = await albumService.addAdditionalAssetToAlbum(
|
||||
assets,
|
||||
album.id,
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
if (result.alreadyInAlbum.isNotEmpty) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Already in ${album.albumName}',
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Added to ${album.albumName}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
return Card(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(15),
|
||||
topRight: Radius.circular(15),
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomDraggingHandle(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Add to album',
|
||||
style: Theme.of(context).textTheme.headline2,
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New album'),
|
||||
onPressed: () {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
ref.watch(assetSelectionProvider.notifier).addNewAssets(assets);
|
||||
AutoRouter.of(context).push(
|
||||
CreateAlbumRoute(
|
||||
isSharedAlbum: false,
|
||||
initialAssets: assets,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (sharedAlbums.isNotEmpty)
|
||||
ExpansionTile(
|
||||
title: const Text('Shared'),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
leading: const Icon(Icons.group),
|
||||
children: sharedAlbums.map((album) =>
|
||||
AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: () => addToAlbum(album),
|
||||
),
|
||||
).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
... albums.map((album) =>
|
||||
AlbumThumbnailListTile(
|
||||
album: album,
|
||||
onTap: () => addToAlbum(album),
|
||||
),
|
||||
).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,9 +56,10 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap ?? () {
|
||||
AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id));
|
||||
},
|
||||
onTap: onTap ??
|
||||
() {
|
||||
AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id));
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Row(
|
||||
|
||||
@@ -96,6 +96,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
|
||||
if (isSuccess) {
|
||||
Navigator.pop(context);
|
||||
ref.watch(assetSelectionProvider.notifier).disableMultiselection();
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.invalidate(sharedAlbumDetailProvider(albumId));
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
|
||||
@@ -62,6 +65,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
|
||||
if (addAssetsResult != null &&
|
||||
addAssetsResult.successfullyAdded > 0) {
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.invalidate(sharedAlbumDetailProvider(albumId));
|
||||
}
|
||||
|
||||
@@ -246,32 +250,45 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> onWillPop() async {
|
||||
final isMultiselectEnable = ref.read(assetSelectionProvider).selectedAssetsInAlbumViewer.isNotEmpty;
|
||||
if (isMultiselectEnable) {
|
||||
ref.watch(assetSelectionProvider.notifier).removeAll();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Widget buildBody(AlbumResponseDto albumInfo) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
titleFocusNode.unfocus();
|
||||
},
|
||||
child: DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
return WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
titleFocusNode.unfocus();
|
||||
},
|
||||
child: DraggableScrollbar.semicircle(
|
||||
backgroundColor: Theme.of(context).hintColor,
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
buildHeader(albumInfo),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: ImmichSliverPersistentAppBarDelegate(
|
||||
minHeight: 50,
|
||||
maxHeight: 50,
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: buildControlButton(albumInfo),
|
||||
heightScrollThumb: 48.0,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
buildHeader(albumInfo),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: ImmichSliverPersistentAppBarDelegate(
|
||||
minHeight: 50,
|
||||
maxHeight: 50,
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: buildControlButton(albumInfo),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
buildImageGrid(albumInfo)
|
||||
],
|
||||
buildImageGrid(albumInfo)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart'
|
||||
show AssetEntityImageProvider, ThumbnailSize;
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
enum _RemoteImageStatus { empty, thumbnail, preview, full }
|
||||
|
||||
class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
late ImageProvider _imageProvider;
|
||||
_RemoteImageStatus _status = _RemoteImageStatus.empty;
|
||||
bool _zoomedIn = false;
|
||||
|
||||
late ImageProvider _fullProvider;
|
||||
late ImageProvider _previewProvider;
|
||||
late ImageProvider _thumbnailProvider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool forbidZoom = _status == _RemoteImageStatus.thumbnail;
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: forbidZoom,
|
||||
child: Listener(
|
||||
onPointerMove: handleSwipUpDown,
|
||||
child: PhotoView(
|
||||
imageProvider: _imageProvider,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
enablePanAlways: false,
|
||||
scaleStateChangedCallback: _scaleStateChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void handleSwipUpDown(PointerMoveEvent details) {
|
||||
int sensitivity = 15;
|
||||
|
||||
if (_zoomedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (details.delta.dy > sensitivity) {
|
||||
widget.onSwipeDown();
|
||||
} else if (details.delta.dy < -sensitivity) {
|
||||
widget.onSwipeUp();
|
||||
}
|
||||
}
|
||||
|
||||
void _scaleStateChanged(PhotoViewScaleState state) {
|
||||
_zoomedIn = state != PhotoViewScaleState.initial;
|
||||
if (_zoomedIn) {
|
||||
widget.isZoomedListener.value = true;
|
||||
} else {
|
||||
widget.isZoomedListener.value = false;
|
||||
}
|
||||
widget.isZoomedFunction();
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider _authorizedImageProvider(
|
||||
String url,
|
||||
String cacheKey,
|
||||
) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
headers: {"Authorization": widget.authToken},
|
||||
cacheKey: cacheKey,
|
||||
);
|
||||
}
|
||||
|
||||
void _performStateTransition(
|
||||
_RemoteImageStatus newStatus,
|
||||
ImageProvider provider,
|
||||
) {
|
||||
if (_status == newStatus) return;
|
||||
|
||||
if (_status == _RemoteImageStatus.full &&
|
||||
newStatus == _RemoteImageStatus.thumbnail) return;
|
||||
|
||||
if (_status == _RemoteImageStatus.preview &&
|
||||
newStatus == _RemoteImageStatus.thumbnail) return;
|
||||
|
||||
if (_status == _RemoteImageStatus.full &&
|
||||
newStatus == _RemoteImageStatus.preview) return;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_status = newStatus;
|
||||
_imageProvider = provider;
|
||||
});
|
||||
}
|
||||
|
||||
void _loadImages() {
|
||||
if (widget.asset.isLocal) {
|
||||
_imageProvider = AssetEntityImageProvider(
|
||||
widget.asset.local!,
|
||||
isOriginal: false,
|
||||
thumbnailSize: const ThumbnailSize.square(250),
|
||||
);
|
||||
_fullProvider = AssetEntityImageProvider(widget.asset.local!);
|
||||
_fullProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo image, _) {
|
||||
_performStateTransition(
|
||||
_RemoteImageStatus.full,
|
||||
_fullProvider,
|
||||
);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_thumbnailProvider = _authorizedImageProvider(
|
||||
getThumbnailUrl(widget.asset.remote!),
|
||||
getThumbnailCacheKey(widget.asset.remote!),
|
||||
);
|
||||
_imageProvider = _thumbnailProvider;
|
||||
|
||||
_thumbnailProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
_performStateTransition(
|
||||
_RemoteImageStatus.thumbnail,
|
||||
_thumbnailProvider,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
if (widget.loadPreview) {
|
||||
_previewProvider = _authorizedImageProvider(
|
||||
getThumbnailUrl(widget.asset.remote!, type: ThumbnailFormat.JPEG),
|
||||
getThumbnailCacheKey(widget.asset.remote!, type: ThumbnailFormat.JPEG),
|
||||
);
|
||||
_previewProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
_performStateTransition(_RemoteImageStatus.preview, _previewProvider);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.loadOriginal) {
|
||||
_fullProvider = _authorizedImageProvider(
|
||||
getImageUrl(widget.asset.remote!),
|
||||
getImageCacheKey(widget.asset.remote!),
|
||||
);
|
||||
_fullProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
_performStateTransition(_RemoteImageStatus.full, _fullProvider);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadImages();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() async {
|
||||
super.dispose();
|
||||
|
||||
if (_status == _RemoteImageStatus.full) {
|
||||
await _fullProvider.evict();
|
||||
} else if (_status == _RemoteImageStatus.preview) {
|
||||
await _previewProvider.evict();
|
||||
} else if (_status == _RemoteImageStatus.thumbnail) {
|
||||
await _thumbnailProvider.evict();
|
||||
}
|
||||
|
||||
await _imageProvider.evict();
|
||||
}
|
||||
}
|
||||
|
||||
class RemotePhotoView extends StatefulWidget {
|
||||
const RemotePhotoView({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.authToken,
|
||||
required this.loadPreview,
|
||||
required this.loadOriginal,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.onSwipeDown,
|
||||
required this.onSwipeUp,
|
||||
}) : super(key: key);
|
||||
|
||||
final Asset asset;
|
||||
final String authToken;
|
||||
final bool loadPreview;
|
||||
final bool loadOriginal;
|
||||
final void Function() onSwipeDown;
|
||||
final void Function() onSwipeUp;
|
||||
final void Function() isZoomedFunction;
|
||||
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _RemotePhotoViewState();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -6,18 +9,24 @@ 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/ui/add_to_album_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/album/ui/add_to_album_list.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_diaglog.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/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';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:openapi/api.dart' as api;
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class GalleryViewerPage extends HookConsumerWidget {
|
||||
@@ -41,7 +50,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
final isZoomed = useState<bool>(false);
|
||||
final indexOfAsset = useState(assetList.indexOf(asset));
|
||||
final isPlayingMotionVideo = useState(false);
|
||||
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
|
||||
late Offset localPosition;
|
||||
final authToken = 'Bearer ${box.get(accessTokenKey)}';
|
||||
|
||||
PageController controller =
|
||||
PageController(initialPage: assetList.indexOf(asset));
|
||||
@@ -58,7 +68,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
[],
|
||||
);
|
||||
|
||||
getAssetExif() async {
|
||||
void getAssetExif() async {
|
||||
if (assetList[indexOfAsset.value].isRemote) {
|
||||
assetDetail = await ref
|
||||
.watch(assetServiceProvider)
|
||||
@@ -69,27 +79,96 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void showInfo() {
|
||||
showModalBottomSheet(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
/// Thumbnail image of a remote asset. Required asset.remote != null
|
||||
ImageProvider remoteThumbnailImageProvider(Asset asset, api.ThumbnailFormat type) {
|
||||
return CachedNetworkImageProvider(
|
||||
getThumbnailUrl(
|
||||
asset.remote!,
|
||||
type: type,
|
||||
),
|
||||
barrierColor: Colors.transparent,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
||||
},
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset.remote!,
|
||||
type: type,
|
||||
),
|
||||
headers: {"Authorization": authToken},
|
||||
);
|
||||
}
|
||||
|
||||
//make isZoomed listener call instead
|
||||
void isZoomedMethod() {
|
||||
if (isZoomedListener.value) {
|
||||
isZoomed.value = true;
|
||||
} else {
|
||||
isZoomed.value = false;
|
||||
/// Original (large) image of a remote asset. Required asset.remote != null
|
||||
ImageProvider originalImageProvider(Asset asset) {
|
||||
return CachedNetworkImageProvider(
|
||||
getImageUrl(asset.remote!),
|
||||
cacheKey: getImageCacheKey(asset.remote!),
|
||||
headers: {"Authorization": authToken},
|
||||
);
|
||||
}
|
||||
|
||||
/// Thumbnail image of a local asset. Required asset.local != null
|
||||
ImageProvider localThumbnailImageProvider(Asset asset) {
|
||||
return AssetEntityImageProvider(
|
||||
asset.local!,
|
||||
isOriginal: false,
|
||||
thumbnailSize: const ThumbnailSize.square(250),
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/// Original (large) image of a local asset. Required asset.local != null
|
||||
ImageProvider localImageProvider(Asset asset) {
|
||||
return AssetEntityImageProvider(asset.local!);
|
||||
}
|
||||
|
||||
void precacheNextImage(int index) {
|
||||
if (index < assetList.length && index > 0) {
|
||||
final asset = assetList[index];
|
||||
if (asset.isLocal) {
|
||||
// Preload the local asset
|
||||
precacheImage(localImageProvider(asset), context);
|
||||
} else {
|
||||
// Probably load WEBP either way
|
||||
precacheImage(
|
||||
remoteThumbnailImageProvider(
|
||||
asset,
|
||||
api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
context,
|
||||
);
|
||||
if (isLoadPreview.value) {
|
||||
// Precache the JPEG thumbnail
|
||||
precacheImage(
|
||||
remoteThumbnailImageProvider(
|
||||
asset,
|
||||
api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
context,
|
||||
);
|
||||
}
|
||||
if (isLoadOriginal.value) {
|
||||
// Preload the original asset
|
||||
precacheImage(
|
||||
originalImageProvider(asset),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void showInfo() {
|
||||
if (assetList[indexOfAsset.value].isRemote) {
|
||||
showModalBottomSheet(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
),
|
||||
barrierColor: Colors.transparent,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +202,28 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void handleSwipeUpDown(DragUpdateDetails details) {
|
||||
int sensitivity = 15;
|
||||
int dxThreshhold = 50;
|
||||
|
||||
if (isZoomed.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for delta from initial down point
|
||||
final d = details.localPosition - localPosition;
|
||||
// If the magnitude of the dx swipe is large, we probably didn't mean to go down
|
||||
if (d.dx.abs() > dxThreshhold) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (details.delta.dy > sensitivity) {
|
||||
AutoRouter.of(context).pop();
|
||||
} else if (details.delta.dy < -sensitivity) {
|
||||
showInfo();
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: TopControlAppBar(
|
||||
@@ -151,61 +252,93 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: PageView.builder(
|
||||
controller: controller,
|
||||
pageSnapping: true,
|
||||
physics: isZoomed.value
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const BouncingScrollPhysics(),
|
||||
child: PhotoViewGallery.builder(
|
||||
scaleStateChangedCallback: (state) => isZoomed.value = state != PhotoViewScaleState.initial,
|
||||
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();
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
getAssetExif();
|
||||
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 acheive
|
||||
// Three-Stage Loading (WEBP -> JPEG -> Original)
|
||||
final webPThumbnail = CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.WEBP),
|
||||
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.WEBP),
|
||||
httpHeaders: { 'Authorization': authToken },
|
||||
progressIndicatorBuilder: (_, __, ___) => const Center(child: ImmichLoadingIndicator(),),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
|
||||
if (assetList[index].isImage) {
|
||||
if (isPlayingMotionVideo.value) {
|
||||
return VideoViewerPage(
|
||||
asset: assetList[index],
|
||||
isMotionVideo: true,
|
||||
onVideoEnded: () {
|
||||
isPlayingMotionVideo.value = false;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return ImageViewerPage(
|
||||
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
||||
isZoomedFunction: isZoomedMethod,
|
||||
isZoomedListener: isZoomedListener,
|
||||
asset: assetList[index],
|
||||
heroTag: assetList[index].id,
|
||||
loadPreview: isLoadPreview.value,
|
||||
loadOriginal: isLoadOriginal.value,
|
||||
showExifSheet: showInfo,
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.JPEG),
|
||||
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.JPEG),
|
||||
httpHeaders: { 'Authorization': authToken },
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_, __) => webPThumbnail,
|
||||
);
|
||||
} else {
|
||||
return GestureDetector(
|
||||
onVerticalDragUpdate: (details) {
|
||||
const int sensitivity = 15;
|
||||
if (details.delta.dy > sensitivity) {
|
||||
// swipe down
|
||||
AutoRouter.of(context).pop();
|
||||
} else if (details.delta.dy < -sensitivity) {
|
||||
// swipe up
|
||||
showInfo();
|
||||
}
|
||||
},
|
||||
child: Hero(
|
||||
tag: assetList[index].id,
|
||||
child: VideoViewerPage(
|
||||
asset: assetList[index],
|
||||
isMotionVideo: false,
|
||||
onVideoEnded: () {},
|
||||
),
|
||||
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 {
|
||||
provider = remoteThumbnailImageProvider(
|
||||
assetList[index],
|
||||
api.ThumbnailFormat.JPEG,
|
||||
);
|
||||
}
|
||||
}
|
||||
return PhotoViewGalleryPageOptions(
|
||||
onDragStart: (_, details, __) => localPosition = details.localPosition,
|
||||
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
|
||||
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),
|
||||
child: VideoViewerPage(
|
||||
asset: assetList[index],
|
||||
isMotionVideo: isPlayingMotionVideo.value,
|
||||
onVideoEnded: () {
|
||||
if (isPlayingMotionVideo.value) {
|
||||
isPlayingMotionVideo.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -215,3 +348,4 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class ImageViewerPage extends HookConsumerWidget {
|
||||
final String heroTag;
|
||||
final Asset asset;
|
||||
final String authToken;
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
final void Function() isZoomedFunction;
|
||||
final void Function()? showExifSheet;
|
||||
final bool loadPreview;
|
||||
final bool loadOriginal;
|
||||
|
||||
ImageViewerPage({
|
||||
Key? key,
|
||||
required this.heroTag,
|
||||
required this.asset,
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.loadPreview,
|
||||
required this.loadOriginal,
|
||||
this.showExifSheet,
|
||||
}) : super(key: key);
|
||||
|
||||
Asset? assetDetail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus =
|
||||
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
|
||||
getAssetExif() async {
|
||||
if (asset.isRemote) {
|
||||
assetDetail =
|
||||
await ref.watch(assetServiceProvider).getAssetById(asset.id);
|
||||
} else {
|
||||
// TODO local exif parsing?
|
||||
assetDetail = asset;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
getAssetExif();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: RemotePhotoView(
|
||||
asset: asset,
|
||||
authToken: authToken,
|
||||
loadPreview: loadPreview,
|
||||
loadOriginal: loadOriginal,
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||
onSwipeUp: (asset.isRemote && showExifSheet != null) ? showExifSheet! : () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||
const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ final backupServiceProvider = Provider(
|
||||
);
|
||||
|
||||
class BackupService {
|
||||
final httpClient = http.Client();
|
||||
final ApiService _apiService;
|
||||
|
||||
BackupService(this._apiService);
|
||||
@@ -282,7 +283,8 @@ class BackupService {
|
||||
),
|
||||
);
|
||||
|
||||
var response = await req.send(cancellationToken: cancelToken);
|
||||
var response =
|
||||
await httpClient.send(req, cancellationToken: cancelToken);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// asset is a duplicate (already exists on the server)
|
||||
@@ -334,7 +336,6 @@ class BackupService {
|
||||
|
||||
Future<MultipartFile?> _getLivePhotoFile(AssetEntity entity) async {
|
||||
var motionFilePath = await entity.getMediaUrl();
|
||||
// var motionFilePath = '/var/mobile/Media/DCIM/103APPLE/IMG_3371.MOV'
|
||||
|
||||
if (motionFilePath != null) {
|
||||
var validPath = motionFilePath.replaceAll('file://', '');
|
||||
|
||||
@@ -200,34 +200,46 @@ class HomePage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
bottom: !multiselectEnabled.state,
|
||||
top: true,
|
||||
child: Stack(
|
||||
children: [
|
||||
ref.watch(assetProvider).renderList == null ||
|
||||
ref.watch(assetProvider).allAssets.isEmpty
|
||||
? buildLoadingIndicator()
|
||||
: ImmichAssetGrid(
|
||||
renderList: ref.watch(assetProvider).renderList!,
|
||||
allAssets: ref.watch(assetProvider).allAssets,
|
||||
assetsPerRow: appSettingService
|
||||
.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
showStorageIndicator: appSettingService
|
||||
.getSetting(AppSettingsEnum.storageIndicator),
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
),
|
||||
if (selectionEnabledHook.value)
|
||||
ControlBottomAppBar(
|
||||
onShare: onShareAssets,
|
||||
onDelete: onDelete,
|
||||
onAddToAlbum: onAddToAlbum,
|
||||
albums: albums,
|
||||
sharedAlbums: sharedAlbums,
|
||||
onCreateNewAlbum: onCreateNewAlbum,
|
||||
),
|
||||
],
|
||||
Future<bool> onWillPop() async {
|
||||
if (multiselectEnabled.state) {
|
||||
selectionEnabledHook.value = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: onWillPop,
|
||||
child: SafeArea(
|
||||
bottom: !multiselectEnabled.state,
|
||||
top: true,
|
||||
child: Stack(
|
||||
children: [
|
||||
ref.watch(assetProvider).renderList == null ||
|
||||
ref.watch(assetProvider).allAssets.isEmpty
|
||||
? buildLoadingIndicator()
|
||||
: ImmichAssetGrid(
|
||||
renderList: ref.watch(assetProvider).renderList!,
|
||||
allAssets: ref.watch(assetProvider).allAssets,
|
||||
assetsPerRow: appSettingService
|
||||
.getSetting(AppSettingsEnum.tilesPerRow),
|
||||
showStorageIndicator: appSettingService
|
||||
.getSetting(AppSettingsEnum.storageIndicator),
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
),
|
||||
if (selectionEnabledHook.value)
|
||||
ControlBottomAppBar(
|
||||
onShare: onShareAssets,
|
||||
onDelete: onDelete,
|
||||
onAddToAlbum: onAddToAlbum,
|
||||
albums: albums,
|
||||
sharedAlbums: sharedAlbums,
|
||||
onCreateNewAlbum: onCreateNewAlbum,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/modules/album/views/select_additional_user_for_sha
|
||||
import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
|
||||
@@ -52,7 +51,6 @@ part 'router.gr.dart';
|
||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||
),
|
||||
AutoRoute(page: GalleryViewerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
|
||||
|
||||
@@ -48,21 +48,6 @@ class _$AppRouter extends RootStackRouter {
|
||||
child: GalleryViewerPage(
|
||||
key: args.key, assetList: args.assetList, asset: args.asset));
|
||||
},
|
||||
ImageViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<ImageViewerRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: ImageViewerPage(
|
||||
key: args.key,
|
||||
heroTag: args.heroTag,
|
||||
asset: args.asset,
|
||||
authToken: args.authToken,
|
||||
isZoomedFunction: args.isZoomedFunction,
|
||||
isZoomedListener: args.isZoomedListener,
|
||||
loadPreview: args.loadPreview,
|
||||
loadOriginal: args.loadOriginal,
|
||||
showExifSheet: args.showExifSheet));
|
||||
},
|
||||
VideoViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<VideoViewerRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
@@ -204,8 +189,6 @@ class _$AppRouter extends RootStackRouter {
|
||||
]),
|
||||
RouteConfig(GalleryViewerRoute.name,
|
||||
path: '/gallery-viewer-page', guards: [authGuard]),
|
||||
RouteConfig(ImageViewerRoute.name,
|
||||
path: '/image-viewer-page', guards: [authGuard]),
|
||||
RouteConfig(VideoViewerRoute.name,
|
||||
path: '/video-viewer-page', guards: [authGuard]),
|
||||
RouteConfig(BackupControllerRoute.name,
|
||||
@@ -299,71 +282,6 @@ class GalleryViewerRouteArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [ImageViewerPage]
|
||||
class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
||||
ImageViewerRoute(
|
||||
{Key? key,
|
||||
required String heroTag,
|
||||
required Asset asset,
|
||||
required String authToken,
|
||||
required void Function() isZoomedFunction,
|
||||
required ValueNotifier<bool> isZoomedListener,
|
||||
required bool loadPreview,
|
||||
required bool loadOriginal,
|
||||
void Function()? showExifSheet})
|
||||
: super(ImageViewerRoute.name,
|
||||
path: '/image-viewer-page',
|
||||
args: ImageViewerRouteArgs(
|
||||
key: key,
|
||||
heroTag: heroTag,
|
||||
asset: asset,
|
||||
authToken: authToken,
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener,
|
||||
loadPreview: loadPreview,
|
||||
loadOriginal: loadOriginal,
|
||||
showExifSheet: showExifSheet));
|
||||
|
||||
static const String name = 'ImageViewerRoute';
|
||||
}
|
||||
|
||||
class ImageViewerRouteArgs {
|
||||
const ImageViewerRouteArgs(
|
||||
{this.key,
|
||||
required this.heroTag,
|
||||
required this.asset,
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.loadPreview,
|
||||
required this.loadOriginal,
|
||||
this.showExifSheet});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final String heroTag;
|
||||
|
||||
final Asset asset;
|
||||
|
||||
final String authToken;
|
||||
|
||||
final void Function() isZoomedFunction;
|
||||
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
|
||||
final bool loadPreview;
|
||||
|
||||
final bool loadOriginal;
|
||||
|
||||
final void Function()? showExifSheet;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal, showExifSheet: $showExifSheet}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [VideoViewerPage]
|
||||
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
||||
|
||||
@@ -22,7 +22,7 @@ class ShareService {
|
||||
}
|
||||
|
||||
Future<void> shareAssets(List<Asset> assets) async {
|
||||
final downloadedFilePaths = assets.map((asset) async {
|
||||
final downloadedXFiles = assets.map<Future<XFile>>((asset) async {
|
||||
if (asset.isRemote) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName = basename(asset.remote!.originalPath);
|
||||
@@ -33,16 +33,16 @@ class ShareService {
|
||||
isWeb: false,
|
||||
);
|
||||
tempFile.writeAsBytesSync(res.bodyBytes);
|
||||
return tempFile.path;
|
||||
return XFile(tempFile.path);
|
||||
} else {
|
||||
File? f = await asset.local!.file;
|
||||
return f!.path;
|
||||
return XFile(f!.path);
|
||||
}
|
||||
});
|
||||
|
||||
// ignore: deprecated_member_use
|
||||
Share.shareFiles(
|
||||
await Future.wait(downloadedFilePaths),
|
||||
Share.shareXFiles(
|
||||
await Future.wait(downloadedXFiles),
|
||||
sharePositionOrigin: Rect.zero,
|
||||
);
|
||||
}
|
||||
|
||||
653
mobile/lib/shared/ui/photo_view/photo_view.dart
Normal file
653
mobile/lib/shared/ui/photo_view/photo_view.dart
Normal file
@@ -0,0 +1,653 @@
|
||||
library photo_view;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.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';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_wrappers.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||
|
||||
export 'src/controller/photo_view_controller.dart';
|
||||
export 'src/controller/photo_view_scalestate_controller.dart';
|
||||
export 'src/core/photo_view_gesture_detector.dart'
|
||||
show PhotoViewGestureDetectorScope, PhotoViewPageViewScrollPhysics;
|
||||
export 'src/photo_view_computed_scale.dart';
|
||||
export 'src/photo_view_scale_state.dart';
|
||||
export 'src/utils/photo_view_hero_attributes.dart';
|
||||
|
||||
/// A [StatefulWidget] that contains all the photo view rendering elements.
|
||||
///
|
||||
/// Sample code to use within an image:
|
||||
///
|
||||
/// ```
|
||||
/// PhotoView(
|
||||
/// imageProvider: imageProvider,
|
||||
/// loadingBuilder: (context, progress) => Center(
|
||||
/// child: Container(
|
||||
/// width: 20.0,
|
||||
/// height: 20.0,
|
||||
/// child: CircularProgressIndicator(
|
||||
/// value: _progress == null
|
||||
/// ? null
|
||||
/// : _progress.cumulativeBytesLoaded /
|
||||
/// _progress.expectedTotalBytes,
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// backgroundDecoration: BoxDecoration(color: Colors.black),
|
||||
/// gaplessPlayback: false,
|
||||
/// customSize: MediaQuery.of(context).size,
|
||||
/// heroAttributes: const HeroAttributes(
|
||||
/// tag: "someTag",
|
||||
/// transitionOnUserGestures: true,
|
||||
/// ),
|
||||
/// scaleStateChangedCallback: this.onScaleStateChanged,
|
||||
/// enableRotation: true,
|
||||
/// controller: controller,
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.8,
|
||||
/// initialScale: PhotoViewComputedScale.contained,
|
||||
/// basePosition: Alignment.center,
|
||||
/// scaleStateCycle: scaleStateCycle
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// You can customize to show an custom child instead of an image:
|
||||
///
|
||||
/// ```
|
||||
/// PhotoView.customChild(
|
||||
/// child: Container(
|
||||
/// width: 220.0,
|
||||
/// height: 250.0,
|
||||
/// child: const Text(
|
||||
/// "Hello there, this is a text",
|
||||
/// )
|
||||
/// ),
|
||||
/// childSize: const Size(220.0, 250.0),
|
||||
/// backgroundDecoration: BoxDecoration(color: Colors.black),
|
||||
/// gaplessPlayback: false,
|
||||
/// customSize: MediaQuery.of(context).size,
|
||||
/// heroAttributes: const HeroAttributes(
|
||||
/// tag: "someTag",
|
||||
/// transitionOnUserGestures: true,
|
||||
/// ),
|
||||
/// scaleStateChangedCallback: this.onScaleStateChanged,
|
||||
/// enableRotation: true,
|
||||
/// controller: controller,
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.8,
|
||||
/// initialScale: PhotoViewComputedScale.contained,
|
||||
/// basePosition: Alignment.center,
|
||||
/// scaleStateCycle: scaleStateCycle
|
||||
/// );
|
||||
/// ```
|
||||
/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
|
||||
///
|
||||
/// Sample using [maxScale], [minScale] and [initialScale]
|
||||
///
|
||||
/// ```
|
||||
/// PhotoView(
|
||||
/// imageProvider: imageProvider,
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.8,
|
||||
/// initialScale: PhotoViewComputedScale.contained * 1.1,
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// [customSize] is used to define the viewPort size in which the image will be
|
||||
/// scaled to. This argument is rarely used. By default is the size that this widget assumes.
|
||||
///
|
||||
/// The argument [gaplessPlayback] is used to continue showing the old image
|
||||
/// (`true`), or briefly show nothing (`false`), when the [imageProvider]
|
||||
/// changes.By default it's set to `false`.
|
||||
///
|
||||
/// To use within an hero animation, specify [heroAttributes]. When
|
||||
/// [heroAttributes] is specified, the image provider retrieval process should
|
||||
/// be sync.
|
||||
///
|
||||
/// Sample using hero animation:
|
||||
/// ```
|
||||
/// // screen1
|
||||
/// ...
|
||||
/// Hero(
|
||||
/// tag: "someTag",
|
||||
/// child: Image.asset(
|
||||
/// "assets/large-image.jpg",
|
||||
/// width: 150.0
|
||||
/// ),
|
||||
/// )
|
||||
/// // screen2
|
||||
/// ...
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/large-image.jpg"),
|
||||
/// heroAttributes: const HeroAttributes(tag: "someTag"),
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// **Note: If you don't want to the zoomed image do not overlaps the size of the container, use [ClipRect](https://docs.flutter.io/flutter/widgets/ClipRect-class.html)**
|
||||
///
|
||||
/// ## Controllers
|
||||
///
|
||||
/// Controllers, when specified to PhotoView widget, enables the author(you) to listen for state updates through a `Stream` and change those values externally.
|
||||
///
|
||||
/// While [PhotoViewScaleStateController] is only responsible for the `scaleState`, [PhotoViewController] is responsible for all fields os [PhotoViewControllerValue].
|
||||
///
|
||||
/// To use them, pass a instance of those items on [controller] or [scaleStateController];
|
||||
///
|
||||
/// Since those follows the standard controller pattern found in widgets like [PageView] and [ScrollView], whoever instantiates it, should [dispose] it afterwards.
|
||||
///
|
||||
/// Example of [controller] usage, only listening for state changes:
|
||||
///
|
||||
/// ```
|
||||
/// class _ExampleWidgetState extends State<ExampleWidget> {
|
||||
///
|
||||
/// PhotoViewController controller;
|
||||
/// double scaleCopy;
|
||||
///
|
||||
/// @override
|
||||
/// void initState() {
|
||||
/// super.initState();
|
||||
/// controller = PhotoViewController()
|
||||
/// ..outputStateStream.listen(listener);
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// void dispose() {
|
||||
/// controller.dispose();
|
||||
/// super.dispose();
|
||||
/// }
|
||||
///
|
||||
/// void listener(PhotoViewControllerValue value){
|
||||
/// setState((){
|
||||
/// scaleCopy = value.scale;
|
||||
/// })
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return Stack(
|
||||
/// children: <Widget>[
|
||||
/// Positioned.fill(
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/pudim.png"),
|
||||
/// controller: controller,
|
||||
/// );
|
||||
/// ),
|
||||
/// Text("Scale applied: $scaleCopy")
|
||||
/// ],
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// An example of [scaleStateController] with state changes:
|
||||
/// ```
|
||||
/// class _ExampleWidgetState extends State<ExampleWidget> {
|
||||
///
|
||||
/// PhotoViewScaleStateController scaleStateController;
|
||||
///
|
||||
/// @override
|
||||
/// void initState() {
|
||||
/// super.initState();
|
||||
/// scaleStateController = PhotoViewScaleStateController();
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// void dispose() {
|
||||
/// scaleStateController.dispose();
|
||||
/// super.dispose();
|
||||
/// }
|
||||
///
|
||||
/// void goBack(){
|
||||
/// scaleStateController.scaleState = PhotoViewScaleState.originalSize;
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return Stack(
|
||||
/// children: <Widget>[
|
||||
/// Positioned.fill(
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/pudim.png"),
|
||||
/// scaleStateController: scaleStateController,
|
||||
/// );
|
||||
/// ),
|
||||
/// FlatButton(
|
||||
/// child: Text("Go to original size"),
|
||||
/// onPressed: goBack,
|
||||
/// );
|
||||
/// ],
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
class PhotoView extends StatefulWidget {
|
||||
/// Creates a widget that displays a zoomable image.
|
||||
///
|
||||
/// To show an image from the network or from an asset bundle, use their respective
|
||||
/// image providers, ie: [AssetImage] or [NetworkImage]
|
||||
///
|
||||
/// Internally, the image is rendered within an [Image] widget.
|
||||
const PhotoView({
|
||||
Key? key,
|
||||
required this.imageProvider,
|
||||
this.loadingBuilder,
|
||||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.heroAttributes,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.maxScale,
|
||||
this.minScale,
|
||||
this.initialScale,
|
||||
this.basePosition,
|
||||
this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.customSize,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
this.errorBuilder,
|
||||
this.enablePanAlways,
|
||||
}) : child = null,
|
||||
childSize = null,
|
||||
super(key: key);
|
||||
|
||||
/// Creates a widget that displays a zoomable child.
|
||||
///
|
||||
/// It has been created to resemble [PhotoView] behavior within widgets that aren't an image, such as [Container], [Text] or a svg.
|
||||
///
|
||||
/// Instead of a [imageProvider], this constructor will receive a [child] and a [childSize].
|
||||
///
|
||||
const PhotoView.customChild({
|
||||
Key? key,
|
||||
required this.child,
|
||||
this.childSize,
|
||||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.heroAttributes,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.maxScale,
|
||||
this.minScale,
|
||||
this.initialScale,
|
||||
this.basePosition,
|
||||
this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.customSize,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
this.enablePanAlways,
|
||||
}) : errorBuilder = null,
|
||||
imageProvider = null,
|
||||
gaplessPlayback = false,
|
||||
loadingBuilder = null,
|
||||
super(key: key);
|
||||
|
||||
/// Given a [imageProvider] it resolves into an zoomable image widget using. It
|
||||
/// is required
|
||||
final ImageProvider? imageProvider;
|
||||
|
||||
/// While [imageProvider] is not resolved, [loadingBuilder] is called by [PhotoView]
|
||||
/// into the screen, by default it is a centered [CircularProgressIndicator]
|
||||
final LoadingBuilder? loadingBuilder;
|
||||
|
||||
/// Show loadFailedChild when the image failed to load
|
||||
final ImageErrorWidgetBuilder? errorBuilder;
|
||||
|
||||
/// Changes the background behind image, defaults to `Colors.black`.
|
||||
final BoxDecoration? backgroundDecoration;
|
||||
|
||||
/// This is used to keep the state of an image in the gallery (e.g. scale state).
|
||||
/// `false` -> resets the state (default)
|
||||
/// `true` -> keeps the state
|
||||
final bool wantKeepAlive;
|
||||
|
||||
/// This is used to continue showing the old image (`true`), or briefly show
|
||||
/// nothing (`false`), when the `imageProvider` changes. By default it's set
|
||||
/// to `false`.
|
||||
final bool gaplessPlayback;
|
||||
|
||||
/// Attributes that are going to be passed to [PhotoViewCore]'s
|
||||
/// [Hero]. Leave this property undefined if you don't want a hero animation.
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
|
||||
/// Defines the size of the scaling base of the image inside [PhotoView],
|
||||
/// by default it is `MediaQuery.of(context).size`.
|
||||
final Size? customSize;
|
||||
|
||||
/// A [Function] to be called whenever the scaleState changes, this happens when the user double taps the content ou start to pinch-in.
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
|
||||
/// A flag that enables the rotation gesture support
|
||||
final bool enableRotation;
|
||||
|
||||
/// The specified custom child to be shown instead of a image
|
||||
final Widget? child;
|
||||
|
||||
/// The size of the custom [child]. [PhotoView] uses this value to compute the relation between the child and the container's size to calculate the scale value.
|
||||
final Size? childSize;
|
||||
|
||||
/// Defines the maximum size in which the image will be allowed to assume, it
|
||||
/// is proportional to the original image size. Can be either a double (absolute value) or a
|
||||
/// [PhotoViewComputedScale], that can be multiplied by a double
|
||||
final dynamic maxScale;
|
||||
|
||||
/// Defines the minimum size in which the image will be allowed to assume, it
|
||||
/// is proportional to the original image size. Can be either a double (absolute value) or a
|
||||
/// [PhotoViewComputedScale], that can be multiplied by a double
|
||||
final dynamic minScale;
|
||||
|
||||
/// Defines the initial size in which the image will be assume in the mounting of the component, it
|
||||
/// is proportional to the original image size. Can be either a double (absolute value) or a
|
||||
/// [PhotoViewComputedScale], that can be multiplied by a double
|
||||
final dynamic initialScale;
|
||||
|
||||
/// A way to control PhotoView transformation factors externally and listen to its updates
|
||||
final PhotoViewControllerBase? controller;
|
||||
|
||||
/// A way to control PhotoViewScaleState value externally and listen to its updates
|
||||
final PhotoViewScaleStateController? scaleStateController;
|
||||
|
||||
/// The alignment of the scale origin in relation to the widget size. Default is [Alignment.center]
|
||||
final Alignment? basePosition;
|
||||
|
||||
/// Defines de next [PhotoViewScaleState] given the actual one. Default is [defaultScaleStateCycle]
|
||||
final ScaleStateCycle? scaleStateCycle;
|
||||
|
||||
/// A pointer that will trigger a tap has stopped contacting the screen at a
|
||||
/// particular location.
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
|
||||
/// A pointer that will trigger a scale has stopped contacting the screen at a
|
||||
/// particular location.
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
|
||||
/// [HitTestBehavior] to be passed to the internal gesture detector.
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
|
||||
/// Enables tight mode, making background container assume the size of the image/child.
|
||||
/// Useful when inside a [Dialog]
|
||||
final bool? tightMode;
|
||||
|
||||
/// Quality levels for image filters.
|
||||
final FilterQuality? filterQuality;
|
||||
|
||||
// Removes gesture detector if `true`.
|
||||
// Useful when custom gesture detector is used in child widget.
|
||||
final bool? disableGestures;
|
||||
|
||||
/// Enable pan the widget even if it's smaller than the hole parent widget.
|
||||
/// Useful when you want to drag a widget without restrictions.
|
||||
final bool? enablePanAlways;
|
||||
|
||||
bool get _isCustomChild {
|
||||
return child != null;
|
||||
}
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _PhotoViewState();
|
||||
}
|
||||
}
|
||||
|
||||
class _PhotoViewState extends State<PhotoView>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
// image retrieval
|
||||
|
||||
// controller
|
||||
late bool _controlledController;
|
||||
late PhotoViewControllerBase _controller;
|
||||
late bool _controlledScaleStateController;
|
||||
late PhotoViewScaleStateController _scaleStateController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.controller == null) {
|
||||
_controlledController = true;
|
||||
_controller = PhotoViewController();
|
||||
} else {
|
||||
_controlledController = false;
|
||||
_controller = widget.controller!;
|
||||
}
|
||||
|
||||
if (widget.scaleStateController == null) {
|
||||
_controlledScaleStateController = true;
|
||||
_scaleStateController = PhotoViewScaleStateController();
|
||||
} else {
|
||||
_controlledScaleStateController = false;
|
||||
_scaleStateController = widget.scaleStateController!;
|
||||
}
|
||||
|
||||
_scaleStateController.outputScaleStateStream.listen(scaleStateListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PhotoView oldWidget) {
|
||||
if (widget.controller == null) {
|
||||
if (!_controlledController) {
|
||||
_controlledController = true;
|
||||
_controller = PhotoViewController();
|
||||
}
|
||||
} else {
|
||||
_controlledController = false;
|
||||
_controller = widget.controller!;
|
||||
}
|
||||
|
||||
if (widget.scaleStateController == null) {
|
||||
if (!_controlledScaleStateController) {
|
||||
_controlledScaleStateController = true;
|
||||
_scaleStateController = PhotoViewScaleStateController();
|
||||
}
|
||||
} else {
|
||||
_controlledScaleStateController = false;
|
||||
_scaleStateController = widget.scaleStateController!;
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_controlledController) {
|
||||
_controller.dispose();
|
||||
}
|
||||
if (_controlledScaleStateController) {
|
||||
_scaleStateController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void scaleStateListener(PhotoViewScaleState scaleState) {
|
||||
if (widget.scaleStateChangedCallback != null) {
|
||||
widget.scaleStateChangedCallback!(_scaleStateController.scaleState);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return LayoutBuilder(
|
||||
builder: (
|
||||
BuildContext context,
|
||||
BoxConstraints constraints,
|
||||
) {
|
||||
final computedOuterSize = widget.customSize ?? constraints.biggest;
|
||||
final backgroundDecoration = widget.backgroundDecoration ??
|
||||
const BoxDecoration(color: Colors.black);
|
||||
|
||||
return widget._isCustomChild
|
||||
? CustomChildWrapper(
|
||||
childSize: widget.childSize,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
heroAttributes: widget.heroAttributes,
|
||||
scaleStateChangedCallback: widget.scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
controller: _controller,
|
||||
scaleStateController: _scaleStateController,
|
||||
maxScale: widget.maxScale,
|
||||
minScale: widget.minScale,
|
||||
initialScale: widget.initialScale,
|
||||
basePosition: widget.basePosition,
|
||||
scaleStateCycle: widget.scaleStateCycle,
|
||||
onTapUp: widget.onTapUp,
|
||||
onTapDown: widget.onTapDown,
|
||||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
outerSize: computedOuterSize,
|
||||
gestureDetectorBehavior: widget.gestureDetectorBehavior,
|
||||
tightMode: widget.tightMode,
|
||||
filterQuality: widget.filterQuality,
|
||||
disableGestures: widget.disableGestures,
|
||||
enablePanAlways: widget.enablePanAlways,
|
||||
child: widget.child,
|
||||
)
|
||||
: ImageWrapper(
|
||||
imageProvider: widget.imageProvider!,
|
||||
loadingBuilder: widget.loadingBuilder,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
heroAttributes: widget.heroAttributes,
|
||||
scaleStateChangedCallback: widget.scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
controller: _controller,
|
||||
scaleStateController: _scaleStateController,
|
||||
maxScale: widget.maxScale,
|
||||
minScale: widget.minScale,
|
||||
initialScale: widget.initialScale,
|
||||
basePosition: widget.basePosition,
|
||||
scaleStateCycle: widget.scaleStateCycle,
|
||||
onTapUp: widget.onTapUp,
|
||||
onTapDown: widget.onTapDown,
|
||||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
outerSize: computedOuterSize,
|
||||
gestureDetectorBehavior: widget.gestureDetectorBehavior,
|
||||
tightMode: widget.tightMode,
|
||||
filterQuality: widget.filterQuality,
|
||||
disableGestures: widget.disableGestures,
|
||||
errorBuilder: widget.errorBuilder,
|
||||
enablePanAlways: widget.enablePanAlways,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => widget.wantKeepAlive;
|
||||
}
|
||||
|
||||
/// The default [ScaleStateCycle]
|
||||
PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) {
|
||||
switch (actual) {
|
||||
case PhotoViewScaleState.initial:
|
||||
return PhotoViewScaleState.covering;
|
||||
case PhotoViewScaleState.covering:
|
||||
return PhotoViewScaleState.originalSize;
|
||||
case PhotoViewScaleState.originalSize:
|
||||
return PhotoViewScaleState.initial;
|
||||
case PhotoViewScaleState.zoomedIn:
|
||||
case PhotoViewScaleState.zoomedOut:
|
||||
return PhotoViewScaleState.initial;
|
||||
default:
|
||||
return PhotoViewScaleState.initial;
|
||||
}
|
||||
}
|
||||
|
||||
/// A type definition for a [Function] that receives the actual [PhotoViewScaleState] and returns the next one
|
||||
/// It is used internally to walk in the "doubletap gesture cycle".
|
||||
/// It is passed to [PhotoView.scaleStateCycle]
|
||||
typedef ScaleStateCycle = PhotoViewScaleState Function(
|
||||
PhotoViewScaleState actual,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user taps up the photoview region
|
||||
typedef PhotoViewImageTapUpCallback = Function(
|
||||
BuildContext context,
|
||||
TapUpDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user taps down the photoview region
|
||||
typedef PhotoViewImageTapDownCallback = Function(
|
||||
BuildContext context,
|
||||
TapDownDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user drags up
|
||||
typedef PhotoViewImageDragStartCallback = Function(
|
||||
BuildContext context,
|
||||
DragStartDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user drags
|
||||
typedef PhotoViewImageDragUpdateCallback = Function(
|
||||
BuildContext context,
|
||||
DragUpdateDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user taps down the photoview region
|
||||
typedef PhotoViewImageDragEndCallback = Function(
|
||||
BuildContext context,
|
||||
DragEndDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when a user finished scale
|
||||
typedef PhotoViewImageScaleEndCallback = Function(
|
||||
BuildContext context,
|
||||
ScaleEndDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback to show a widget while the image is loading, a [ImageChunkEvent] is passed to inform progress
|
||||
typedef LoadingBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
ImageChunkEvent? event,
|
||||
);
|
||||
446
mobile/lib/shared/ui/photo_view/photo_view_gallery.dart
Normal file
446
mobile/lib/shared/ui/photo_view/photo_view_gallery.dart
Normal file
@@ -0,0 +1,446 @@
|
||||
library photo_view_gallery;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
|
||||
show
|
||||
LoadingBuilder,
|
||||
PhotoView,
|
||||
PhotoViewImageTapDownCallback,
|
||||
PhotoViewImageTapUpCallback,
|
||||
PhotoViewImageDragStartCallback,
|
||||
PhotoViewImageDragEndCallback,
|
||||
PhotoViewImageDragUpdateCallback,
|
||||
PhotoViewImageScaleEndCallback,
|
||||
ScaleStateCycle;
|
||||
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||
|
||||
/// A type definition for a [Function] that receives a index after a page change in [PhotoViewGallery]
|
||||
typedef PhotoViewGalleryPageChangedCallback = void Function(int index);
|
||||
|
||||
/// A type definition for a [Function] that defines a page in [PhotoViewGallery.build]
|
||||
typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function(
|
||||
BuildContext context,
|
||||
int index,
|
||||
);
|
||||
|
||||
/// A [StatefulWidget] that shows multiple [PhotoView] widgets in a [PageView]
|
||||
///
|
||||
/// Some of [PhotoView] constructor options are passed direct to [PhotoViewGallery] constructor. Those options will affect the gallery in a whole.
|
||||
///
|
||||
/// Some of the options may be defined to each image individually, such as `initialScale` or `PhotoViewHeroAttributes`. Those must be passed via each [PhotoViewGalleryPageOptions].
|
||||
///
|
||||
/// Example of usage as a list of options:
|
||||
/// ```
|
||||
/// PhotoViewGallery(
|
||||
/// pageOptions: <PhotoViewGalleryPageOptions>[
|
||||
/// PhotoViewGalleryPageOptions(
|
||||
/// imageProvider: AssetImage("assets/gallery1.jpg"),
|
||||
/// heroAttributes: const PhotoViewHeroAttributes(tag: "tag1"),
|
||||
/// ),
|
||||
/// PhotoViewGalleryPageOptions(
|
||||
/// imageProvider: AssetImage("assets/gallery2.jpg"),
|
||||
/// heroAttributes: const PhotoViewHeroAttributes(tag: "tag2"),
|
||||
/// maxScale: PhotoViewComputedScale.contained * 0.3
|
||||
/// ),
|
||||
/// PhotoViewGalleryPageOptions(
|
||||
/// imageProvider: AssetImage("assets/gallery3.jpg"),
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.1,
|
||||
/// heroAttributes: const HeroAttributes(tag: "tag3"),
|
||||
/// ),
|
||||
/// ],
|
||||
/// loadingBuilder: (context, progress) => Center(
|
||||
/// child: Container(
|
||||
/// width: 20.0,
|
||||
/// height: 20.0,
|
||||
/// child: CircularProgressIndicator(
|
||||
/// value: _progress == null
|
||||
/// ? null
|
||||
/// : _progress.cumulativeBytesLoaded /
|
||||
/// _progress.expectedTotalBytes,
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// backgroundDecoration: widget.backgroundDecoration,
|
||||
/// pageController: widget.pageController,
|
||||
/// onPageChanged: onPageChanged,
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// Example of usage with builder pattern:
|
||||
/// ```
|
||||
/// PhotoViewGallery.builder(
|
||||
/// scrollPhysics: const BouncingScrollPhysics(),
|
||||
/// builder: (BuildContext context, int index) {
|
||||
/// return PhotoViewGalleryPageOptions(
|
||||
/// imageProvider: AssetImage(widget.galleryItems[index].image),
|
||||
/// initialScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.1,
|
||||
/// heroAttributes: HeroAttributes(tag: galleryItems[index].id),
|
||||
/// );
|
||||
/// },
|
||||
/// itemCount: galleryItems.length,
|
||||
/// loadingBuilder: (context, progress) => Center(
|
||||
/// child: Container(
|
||||
/// width: 20.0,
|
||||
/// height: 20.0,
|
||||
/// child: CircularProgressIndicator(
|
||||
/// value: _progress == null
|
||||
/// ? null
|
||||
/// : _progress.cumulativeBytesLoaded /
|
||||
/// _progress.expectedTotalBytes,
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// backgroundDecoration: widget.backgroundDecoration,
|
||||
/// pageController: widget.pageController,
|
||||
/// onPageChanged: onPageChanged,
|
||||
/// )
|
||||
/// ```
|
||||
class PhotoViewGallery extends StatefulWidget {
|
||||
/// Construct a gallery with static items through a list of [PhotoViewGalleryPageOptions].
|
||||
const PhotoViewGallery({
|
||||
Key? key,
|
||||
required this.pageOptions,
|
||||
this.loadingBuilder,
|
||||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.reverse = false,
|
||||
this.pageController,
|
||||
this.onPageChanged,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.scrollPhysics,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.customSize,
|
||||
this.allowImplicitScrolling = false,
|
||||
}) : itemCount = null,
|
||||
builder = null,
|
||||
super(key: key);
|
||||
|
||||
/// Construct a gallery with dynamic items.
|
||||
///
|
||||
/// The builder must return a [PhotoViewGalleryPageOptions].
|
||||
const PhotoViewGallery.builder({
|
||||
Key? key,
|
||||
required this.itemCount,
|
||||
required this.builder,
|
||||
this.loadingBuilder,
|
||||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.reverse = false,
|
||||
this.pageController,
|
||||
this.onPageChanged,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.scrollPhysics,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.customSize,
|
||||
this.allowImplicitScrolling = false,
|
||||
}) : pageOptions = null,
|
||||
assert(itemCount != null),
|
||||
assert(builder != null),
|
||||
super(key: key);
|
||||
|
||||
/// A list of options to describe the items in the gallery
|
||||
final List<PhotoViewGalleryPageOptions>? pageOptions;
|
||||
|
||||
/// The count of items in the gallery, only used when constructed via [PhotoViewGallery.builder]
|
||||
final int? itemCount;
|
||||
|
||||
/// Called to build items for the gallery when using [PhotoViewGallery.builder]
|
||||
final PhotoViewGalleryBuilder? builder;
|
||||
|
||||
/// [ScrollPhysics] for the internal [PageView]
|
||||
final ScrollPhysics? scrollPhysics;
|
||||
|
||||
/// Mirror to [PhotoView.loadingBuilder]
|
||||
final LoadingBuilder? loadingBuilder;
|
||||
|
||||
/// Mirror to [PhotoView.backgroundDecoration]
|
||||
final BoxDecoration? backgroundDecoration;
|
||||
|
||||
/// Mirror to [PhotoView.wantKeepAlive]
|
||||
final bool wantKeepAlive;
|
||||
|
||||
/// Mirror to [PhotoView.gaplessPlayback]
|
||||
final bool gaplessPlayback;
|
||||
|
||||
/// Mirror to [PageView.reverse]
|
||||
final bool reverse;
|
||||
|
||||
/// An object that controls the [PageView] inside [PhotoViewGallery]
|
||||
final PageController? pageController;
|
||||
|
||||
/// An callback to be called on a page change
|
||||
final PhotoViewGalleryPageChangedCallback? onPageChanged;
|
||||
|
||||
/// Mirror to [PhotoView.scaleStateChangedCallback]
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
|
||||
/// Mirror to [PhotoView.enableRotation]
|
||||
final bool enableRotation;
|
||||
|
||||
/// Mirror to [PhotoView.customSize]
|
||||
final Size? customSize;
|
||||
|
||||
/// The axis along which the [PageView] scrolls. Mirror to [PageView.scrollDirection]
|
||||
final Axis scrollDirection;
|
||||
|
||||
/// When user attempts to move it to the next element, focus will traverse to the next page in the page view.
|
||||
final bool allowImplicitScrolling;
|
||||
|
||||
bool get _isBuilder => builder != null;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _PhotoViewGalleryState();
|
||||
}
|
||||
}
|
||||
|
||||
class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
||||
late final PageController _controller =
|
||||
widget.pageController ?? PageController();
|
||||
|
||||
void scaleStateChangedCallback(PhotoViewScaleState scaleState) {
|
||||
if (widget.scaleStateChangedCallback != null) {
|
||||
widget.scaleStateChangedCallback!(scaleState);
|
||||
}
|
||||
}
|
||||
|
||||
int get actualPage {
|
||||
return _controller.hasClients ? _controller.page!.floor() : 0;
|
||||
}
|
||||
|
||||
int get itemCount {
|
||||
if (widget._isBuilder) {
|
||||
return widget.itemCount!;
|
||||
}
|
||||
return widget.pageOptions!.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Enable corner hit test
|
||||
return PhotoViewGestureDetectorScope(
|
||||
axis: widget.scrollDirection,
|
||||
child: PageView.builder(
|
||||
reverse: widget.reverse,
|
||||
controller: _controller,
|
||||
onPageChanged: widget.onPageChanged,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: _buildItem,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
physics: widget.scrollPhysics,
|
||||
allowImplicitScrolling: widget.allowImplicitScrolling,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(BuildContext context, int index) {
|
||||
final pageOption = _buildPageOption(context, index);
|
||||
final isCustomChild = pageOption.child != null;
|
||||
|
||||
final PhotoView photoView = isCustomChild
|
||||
? PhotoView.customChild(
|
||||
key: ObjectKey(index),
|
||||
childSize: pageOption.childSize,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
wantKeepAlive: widget.wantKeepAlive,
|
||||
controller: pageOption.controller,
|
||||
scaleStateController: pageOption.scaleStateController,
|
||||
customSize: widget.customSize,
|
||||
heroAttributes: pageOption.heroAttributes,
|
||||
scaleStateChangedCallback: scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
initialScale: pageOption.initialScale,
|
||||
minScale: pageOption.minScale,
|
||||
maxScale: pageOption.maxScale,
|
||||
scaleStateCycle: pageOption.scaleStateCycle,
|
||||
onTapUp: pageOption.onTapUp,
|
||||
onTapDown: pageOption.onTapDown,
|
||||
onDragStart: pageOption.onDragStart,
|
||||
onDragEnd: pageOption.onDragEnd,
|
||||
onDragUpdate: pageOption.onDragUpdate,
|
||||
onScaleEnd: pageOption.onScaleEnd,
|
||||
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
|
||||
tightMode: pageOption.tightMode,
|
||||
filterQuality: pageOption.filterQuality,
|
||||
basePosition: pageOption.basePosition,
|
||||
disableGestures: pageOption.disableGestures,
|
||||
child: pageOption.child,
|
||||
)
|
||||
: PhotoView(
|
||||
key: ObjectKey(index),
|
||||
imageProvider: pageOption.imageProvider,
|
||||
loadingBuilder: widget.loadingBuilder,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
wantKeepAlive: widget.wantKeepAlive,
|
||||
controller: pageOption.controller,
|
||||
scaleStateController: pageOption.scaleStateController,
|
||||
customSize: widget.customSize,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
heroAttributes: pageOption.heroAttributes,
|
||||
scaleStateChangedCallback: scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
initialScale: pageOption.initialScale,
|
||||
minScale: pageOption.minScale,
|
||||
maxScale: pageOption.maxScale,
|
||||
scaleStateCycle: pageOption.scaleStateCycle,
|
||||
onTapUp: pageOption.onTapUp,
|
||||
onTapDown: pageOption.onTapDown,
|
||||
onDragStart: pageOption.onDragStart,
|
||||
onDragEnd: pageOption.onDragEnd,
|
||||
onDragUpdate: pageOption.onDragUpdate,
|
||||
onScaleEnd: pageOption.onScaleEnd,
|
||||
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
|
||||
tightMode: pageOption.tightMode,
|
||||
filterQuality: pageOption.filterQuality,
|
||||
basePosition: pageOption.basePosition,
|
||||
disableGestures: pageOption.disableGestures,
|
||||
errorBuilder: pageOption.errorBuilder,
|
||||
);
|
||||
|
||||
return ClipRect(
|
||||
child: photoView,
|
||||
);
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _buildPageOption(BuildContext context, int index) {
|
||||
if (widget._isBuilder) {
|
||||
return widget.builder!(context, index);
|
||||
}
|
||||
return widget.pageOptions![index];
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper class that wraps individual options of a page in [PhotoViewGallery]
|
||||
///
|
||||
/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
|
||||
///
|
||||
class PhotoViewGalleryPageOptions {
|
||||
PhotoViewGalleryPageOptions({
|
||||
Key? key,
|
||||
required this.imageProvider,
|
||||
this.heroAttributes,
|
||||
this.minScale,
|
||||
this.maxScale,
|
||||
this.initialScale,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.basePosition,
|
||||
this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
this.errorBuilder,
|
||||
}) : child = null,
|
||||
childSize = null,
|
||||
assert(imageProvider != null);
|
||||
|
||||
PhotoViewGalleryPageOptions.customChild({
|
||||
required this.child,
|
||||
this.childSize,
|
||||
this.heroAttributes,
|
||||
this.minScale,
|
||||
this.maxScale,
|
||||
this.initialScale,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.basePosition,
|
||||
this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
}) : errorBuilder = null,
|
||||
imageProvider = null;
|
||||
|
||||
/// Mirror to [PhotoView.imageProvider]
|
||||
final ImageProvider? imageProvider;
|
||||
|
||||
/// Mirror to [PhotoView.heroAttributes]
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
|
||||
/// Mirror to [PhotoView.minScale]
|
||||
final dynamic minScale;
|
||||
|
||||
/// Mirror to [PhotoView.maxScale]
|
||||
final dynamic maxScale;
|
||||
|
||||
/// Mirror to [PhotoView.initialScale]
|
||||
final dynamic initialScale;
|
||||
|
||||
/// Mirror to [PhotoView.controller]
|
||||
final PhotoViewController? controller;
|
||||
|
||||
/// Mirror to [PhotoView.scaleStateController]
|
||||
final PhotoViewScaleStateController? scaleStateController;
|
||||
|
||||
/// Mirror to [PhotoView.basePosition]
|
||||
final Alignment? basePosition;
|
||||
|
||||
/// Mirror to [PhotoView.child]
|
||||
final Widget? child;
|
||||
|
||||
/// Mirror to [PhotoView.childSize]
|
||||
final Size? childSize;
|
||||
|
||||
/// Mirror to [PhotoView.scaleStateCycle]
|
||||
final ScaleStateCycle? scaleStateCycle;
|
||||
|
||||
/// Mirror to [PhotoView.onTapUp]
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
|
||||
/// Mirror to [PhotoView.onDragUp]
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
|
||||
/// Mirror to [PhotoView.onDragDown]
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
|
||||
/// Mirror to [PhotoView.onDraUpdate]
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
|
||||
/// Mirror to [PhotoView.onTapDown]
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
|
||||
/// Mirror to [PhotoView.onScaleEnd]
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
|
||||
/// Mirror to [PhotoView.gestureDetectorBehavior]
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
|
||||
/// Mirror to [PhotoView.tightMode]
|
||||
final bool? tightMode;
|
||||
|
||||
/// Mirror to [PhotoView.disableGestures]
|
||||
final bool? disableGestures;
|
||||
|
||||
/// Quality levels for image filters.
|
||||
final FilterQuality? filterQuality;
|
||||
|
||||
/// Mirror to [PhotoView.errorBuilder]
|
||||
final ImageErrorWidgetBuilder? errorBuilder;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
|
||||
|
||||
/// The interface in which controllers will be implemented.
|
||||
///
|
||||
/// It concerns storing the state ([PhotoViewControllerValue]) and streaming its updates.
|
||||
/// [PhotoViewImageWrapper] will respond to user gestures setting thew fields in the instance of a controller.
|
||||
///
|
||||
/// Any instance of a controller must be disposed after unmount. So if you instantiate a [PhotoViewController] or your custom implementation, do not forget to dispose it when not using it anymore.
|
||||
///
|
||||
/// The controller exposes value fields like [scale] or [rotationFocus]. Usually those fields will be only getters and setters serving as hooks to the internal [PhotoViewControllerValue].
|
||||
///
|
||||
/// The default implementation used by [PhotoView] is [PhotoViewController].
|
||||
///
|
||||
/// This was created to allow customization (you can create your own controller class)
|
||||
///
|
||||
/// Previously it controlled `scaleState` as well, but duw to some [concerns](https://github.com/renancaraujo/photo_view/issues/127)
|
||||
/// [ScaleStateListener is responsible for tat value now
|
||||
///
|
||||
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
|
||||
///
|
||||
abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
|
||||
/// The output for state/value updates. Usually a broadcast [Stream]
|
||||
Stream<T> get outputStateStream;
|
||||
|
||||
/// The state value before the last change or the initial state if the state has not been changed.
|
||||
late T prevValue;
|
||||
|
||||
/// The actual state value
|
||||
late T value;
|
||||
|
||||
/// Resets the state to the initial value;
|
||||
void reset();
|
||||
|
||||
/// Closes streams and removes eventual listeners.
|
||||
void dispose();
|
||||
|
||||
/// Add a listener that will ignore updates made internally
|
||||
///
|
||||
/// Since it is made for internal use, it is not performatic to use more than one
|
||||
/// listener. Prefer [outputStateStream]
|
||||
void addIgnorableListener(VoidCallback callback);
|
||||
|
||||
/// Remove a listener that will ignore updates made internally
|
||||
///
|
||||
/// Since it is made for internal use, it is not performatic to use more than one
|
||||
/// listener. Prefer [outputStateStream]
|
||||
void removeIgnorableListener(VoidCallback callback);
|
||||
|
||||
/// The position of the image in the screen given its offset after pan gestures.
|
||||
late Offset position;
|
||||
|
||||
/// The scale factor to transform the child (image or a customChild).
|
||||
late double? scale;
|
||||
|
||||
/// Nevermind this method :D, look away
|
||||
void setScaleInvisibly(double? scale);
|
||||
|
||||
/// The rotation factor to transform the child (image or a customChild).
|
||||
late double rotation;
|
||||
|
||||
/// The center of the rotation transformation. It is a coordinate referring to the absolute dimensions of the image.
|
||||
Offset? rotationFocusPoint;
|
||||
|
||||
/// Update multiple fields of the state with only one update streamed.
|
||||
void updateMultiple({
|
||||
Offset? position,
|
||||
double? scale,
|
||||
double? rotation,
|
||||
Offset? rotationFocusPoint,
|
||||
});
|
||||
}
|
||||
|
||||
/// The state value stored and streamed by [PhotoViewController].
|
||||
@immutable
|
||||
class PhotoViewControllerValue {
|
||||
const PhotoViewControllerValue({
|
||||
required this.position,
|
||||
required this.scale,
|
||||
required this.rotation,
|
||||
required this.rotationFocusPoint,
|
||||
});
|
||||
|
||||
final Offset position;
|
||||
final double? scale;
|
||||
final double rotation;
|
||||
final Offset? rotationFocusPoint;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PhotoViewControllerValue &&
|
||||
runtimeType == other.runtimeType &&
|
||||
position == other.position &&
|
||||
scale == other.scale &&
|
||||
rotation == other.rotation &&
|
||||
rotationFocusPoint == other.rotationFocusPoint;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
position.hashCode ^
|
||||
scale.hashCode ^
|
||||
rotation.hashCode ^
|
||||
rotationFocusPoint.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PhotoViewControllerValue{position: $position, scale: $scale, rotation: $rotation, rotationFocusPoint: $rotationFocusPoint}';
|
||||
}
|
||||
}
|
||||
|
||||
/// The default implementation of [PhotoViewControllerBase].
|
||||
///
|
||||
/// Containing a [ValueNotifier] it stores the state in the [value] field and streams
|
||||
/// updates via [outputStateStream].
|
||||
///
|
||||
/// For details of fields and methods, check [PhotoViewControllerBase].
|
||||
///
|
||||
class PhotoViewController
|
||||
implements PhotoViewControllerBase<PhotoViewControllerValue> {
|
||||
PhotoViewController({
|
||||
Offset initialPosition = Offset.zero,
|
||||
double initialRotation = 0.0,
|
||||
double? initialScale,
|
||||
}) : _valueNotifier = IgnorableValueNotifier(
|
||||
PhotoViewControllerValue(
|
||||
position: initialPosition,
|
||||
rotation: initialRotation,
|
||||
scale: initialScale,
|
||||
rotationFocusPoint: null,
|
||||
),
|
||||
),
|
||||
super() {
|
||||
initial = value;
|
||||
prevValue = initial;
|
||||
|
||||
_valueNotifier.addListener(_changeListener);
|
||||
_outputCtrl = StreamController<PhotoViewControllerValue>.broadcast();
|
||||
_outputCtrl.sink.add(initial);
|
||||
}
|
||||
|
||||
final IgnorableValueNotifier<PhotoViewControllerValue> _valueNotifier;
|
||||
|
||||
late PhotoViewControllerValue initial;
|
||||
|
||||
late StreamController<PhotoViewControllerValue> _outputCtrl;
|
||||
|
||||
@override
|
||||
Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream;
|
||||
|
||||
@override
|
||||
late PhotoViewControllerValue prevValue;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
value = initial;
|
||||
}
|
||||
|
||||
void _changeListener() {
|
||||
_outputCtrl.sink.add(value);
|
||||
}
|
||||
|
||||
@override
|
||||
void addIgnorableListener(VoidCallback callback) {
|
||||
_valueNotifier.addIgnorableListener(callback);
|
||||
}
|
||||
|
||||
@override
|
||||
void removeIgnorableListener(VoidCallback callback) {
|
||||
_valueNotifier.removeIgnorableListener(callback);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_outputCtrl.close();
|
||||
_valueNotifier.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
set position(Offset position) {
|
||||
if (value.position == position) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset get position => value.position;
|
||||
|
||||
@override
|
||||
set scale(double? scale) {
|
||||
if (value.scale == scale) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double? get scale => value.scale;
|
||||
|
||||
@override
|
||||
void setScaleInvisibly(double? scale) {
|
||||
if (value.scale == scale) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
_valueNotifier.updateIgnoring(
|
||||
PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
set rotation(double rotation) {
|
||||
if (value.rotation == rotation) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double get rotation => value.rotation;
|
||||
|
||||
@override
|
||||
set rotationFocusPoint(Offset? rotationFocusPoint) {
|
||||
if (value.rotationFocusPoint == rotationFocusPoint) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset? get rotationFocusPoint => value.rotationFocusPoint;
|
||||
|
||||
@override
|
||||
void updateMultiple({
|
||||
Offset? position,
|
||||
double? scale,
|
||||
double? rotation,
|
||||
Offset? rotationFocusPoint,
|
||||
}) {
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position ?? value.position,
|
||||
scale: scale ?? value.scale,
|
||||
rotation: rotation ?? value.rotation,
|
||||
rotationFocusPoint: rotationFocusPoint ?? value.rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
PhotoViewControllerValue get value => _valueNotifier.value;
|
||||
|
||||
@override
|
||||
set value(PhotoViewControllerValue newValue) {
|
||||
if (_valueNotifier.value == newValue) {
|
||||
return;
|
||||
}
|
||||
_valueNotifier.value = newValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
|
||||
show
|
||||
PhotoViewControllerBase,
|
||||
PhotoViewScaleState,
|
||||
PhotoViewScaleStateController,
|
||||
ScaleStateCycle;
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart';
|
||||
|
||||
/// A class to hold internal layout logic to sync both controller states
|
||||
///
|
||||
/// It reacts to layout changes (eg: enter landscape or widget resize) and syncs the two controllers.
|
||||
mixin PhotoViewControllerDelegate on State<PhotoViewCore> {
|
||||
PhotoViewControllerBase get controller => widget.controller;
|
||||
|
||||
PhotoViewScaleStateController get scaleStateController =>
|
||||
widget.scaleStateController;
|
||||
|
||||
ScaleBoundaries get scaleBoundaries => widget.scaleBoundaries;
|
||||
|
||||
ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle;
|
||||
|
||||
Alignment get basePosition => widget.basePosition;
|
||||
Function(double prevScale, double nextScale)? _animateScale;
|
||||
|
||||
/// Mark if scale need recalculation, useful for scale boundaries changes.
|
||||
bool markNeedsScaleRecalc = true;
|
||||
|
||||
void initDelegate() {
|
||||
controller.addIgnorableListener(_blindScaleListener);
|
||||
scaleStateController.addIgnorableListener(_blindScaleStateListener);
|
||||
}
|
||||
|
||||
void _blindScaleStateListener() {
|
||||
if (!scaleStateController.hasChanged) {
|
||||
return;
|
||||
}
|
||||
if (_animateScale == null || scaleStateController.isZooming) {
|
||||
controller.setScaleInvisibly(scale);
|
||||
return;
|
||||
}
|
||||
final double prevScale = controller.scale ??
|
||||
getScaleForScaleState(
|
||||
scaleStateController.prevScaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
|
||||
final double nextScale = getScaleForScaleState(
|
||||
scaleStateController.scaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
|
||||
_animateScale!(prevScale, nextScale);
|
||||
}
|
||||
|
||||
void addAnimateOnScaleStateUpdate(
|
||||
void Function(double prevScale, double nextScale) animateScale,
|
||||
) {
|
||||
_animateScale = animateScale;
|
||||
}
|
||||
|
||||
void _blindScaleListener() {
|
||||
if (!widget.enablePanAlways) {
|
||||
controller.position = clampPosition();
|
||||
}
|
||||
if (controller.scale == controller.prevValue.scale) {
|
||||
return;
|
||||
}
|
||||
final PhotoViewScaleState newScaleState =
|
||||
(scale > scaleBoundaries.initialScale)
|
||||
? PhotoViewScaleState.zoomedIn
|
||||
: PhotoViewScaleState.zoomedOut;
|
||||
|
||||
scaleStateController.setInvisibly(newScaleState);
|
||||
}
|
||||
|
||||
Offset get position => controller.position;
|
||||
|
||||
double get scale {
|
||||
// for figuring out initial scale
|
||||
final needsRecalc = markNeedsScaleRecalc &&
|
||||
!scaleStateController.scaleState.isScaleStateZooming;
|
||||
|
||||
final scaleExistsOnController = controller.scale != null;
|
||||
if (needsRecalc || !scaleExistsOnController) {
|
||||
final newScale = getScaleForScaleState(
|
||||
scaleStateController.scaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
markNeedsScaleRecalc = false;
|
||||
scale = newScale;
|
||||
return newScale;
|
||||
}
|
||||
return controller.scale!;
|
||||
}
|
||||
|
||||
set scale(double scale) => controller.setScaleInvisibly(scale);
|
||||
|
||||
void updateMultiple({
|
||||
Offset? position,
|
||||
double? scale,
|
||||
double? rotation,
|
||||
Offset? rotationFocusPoint,
|
||||
}) {
|
||||
controller.updateMultiple(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
void updateScaleStateFromNewScale(double newScale) {
|
||||
PhotoViewScaleState newScaleState = PhotoViewScaleState.initial;
|
||||
if (scale != scaleBoundaries.initialScale) {
|
||||
newScaleState = (newScale > scaleBoundaries.initialScale)
|
||||
? PhotoViewScaleState.zoomedIn
|
||||
: PhotoViewScaleState.zoomedOut;
|
||||
}
|
||||
scaleStateController.setInvisibly(newScaleState);
|
||||
}
|
||||
|
||||
void nextScaleState() {
|
||||
final PhotoViewScaleState scaleState = scaleStateController.scaleState;
|
||||
if (scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleState == PhotoViewScaleState.zoomedOut) {
|
||||
scaleStateController.scaleState = scaleStateCycle(scaleState);
|
||||
return;
|
||||
}
|
||||
final double originalScale = getScaleForScaleState(
|
||||
scaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
|
||||
double prevScale = originalScale;
|
||||
PhotoViewScaleState prevScaleState = scaleState;
|
||||
double nextScale = originalScale;
|
||||
PhotoViewScaleState nextScaleState = scaleState;
|
||||
|
||||
do {
|
||||
prevScale = nextScale;
|
||||
prevScaleState = nextScaleState;
|
||||
nextScaleState = scaleStateCycle(prevScaleState);
|
||||
nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
|
||||
} while (prevScale == nextScale && scaleState != nextScaleState);
|
||||
|
||||
if (originalScale == nextScale) {
|
||||
return;
|
||||
}
|
||||
scaleStateController.scaleState = nextScaleState;
|
||||
}
|
||||
|
||||
CornersRange cornersX({double? scale}) {
|
||||
final double s = scale ?? this.scale;
|
||||
|
||||
final double computedWidth = scaleBoundaries.childSize.width * s;
|
||||
final double screenWidth = scaleBoundaries.outerSize.width;
|
||||
|
||||
final double positionX = basePosition.x;
|
||||
final double widthDiff = computedWidth - screenWidth;
|
||||
|
||||
final double minX = ((positionX - 1).abs() / 2) * widthDiff * -1;
|
||||
final double maxX = ((positionX + 1).abs() / 2) * widthDiff;
|
||||
return CornersRange(minX, maxX);
|
||||
}
|
||||
|
||||
CornersRange cornersY({double? scale}) {
|
||||
final double s = scale ?? this.scale;
|
||||
|
||||
final double computedHeight = scaleBoundaries.childSize.height * s;
|
||||
final double screenHeight = scaleBoundaries.outerSize.height;
|
||||
|
||||
final double positionY = basePosition.y;
|
||||
final double heightDiff = computedHeight - screenHeight;
|
||||
|
||||
final double minY = ((positionY - 1).abs() / 2) * heightDiff * -1;
|
||||
final double maxY = ((positionY + 1).abs() / 2) * heightDiff;
|
||||
return CornersRange(minY, maxY);
|
||||
}
|
||||
|
||||
Offset clampPosition({Offset? position, double? scale}) {
|
||||
final double s = scale ?? this.scale;
|
||||
final Offset p = position ?? this.position;
|
||||
|
||||
final double computedWidth = scaleBoundaries.childSize.width * s;
|
||||
final double computedHeight = scaleBoundaries.childSize.height * s;
|
||||
|
||||
final double screenWidth = scaleBoundaries.outerSize.width;
|
||||
final double screenHeight = scaleBoundaries.outerSize.height;
|
||||
|
||||
double finalX = 0.0;
|
||||
if (screenWidth < computedWidth) {
|
||||
final cornersX = this.cornersX(scale: s);
|
||||
finalX = p.dx.clamp(cornersX.min, cornersX.max);
|
||||
}
|
||||
|
||||
double finalY = 0.0;
|
||||
if (screenHeight < computedHeight) {
|
||||
final cornersY = this.cornersY(scale: s);
|
||||
finalY = p.dy.clamp(cornersY.min, cornersY.max);
|
||||
}
|
||||
|
||||
return Offset(finalX, finalY);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animateScale = null;
|
||||
controller.removeIgnorableListener(_blindScaleListener);
|
||||
scaleStateController.removeIgnorableListener(_blindScaleStateListener);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/widgets.dart' show VoidCallback;
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
|
||||
|
||||
typedef ScaleStateListener = void Function(double prevScale, double nextScale);
|
||||
|
||||
/// A controller responsible only by [scaleState].
|
||||
///
|
||||
/// Scale state is a common value with represents the step in which the [PhotoView.scaleStateCycle] is.
|
||||
/// This cycle is triggered by the "doubleTap" gesture.
|
||||
///
|
||||
/// Any change in its [scaleState] should animate the scale of image/content.
|
||||
///
|
||||
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
|
||||
///
|
||||
/// The updates should be done via [scaleState] setter and the updated listened via [outputScaleStateStream]
|
||||
///
|
||||
class PhotoViewScaleStateController {
|
||||
late final IgnorableValueNotifier<PhotoViewScaleState> _scaleStateNotifier =
|
||||
IgnorableValueNotifier(PhotoViewScaleState.initial)
|
||||
..addListener(_scaleStateChangeListener);
|
||||
final StreamController<PhotoViewScaleState> _outputScaleStateCtrl =
|
||||
StreamController<PhotoViewScaleState>.broadcast()
|
||||
..sink.add(PhotoViewScaleState.initial);
|
||||
|
||||
/// The output for state/value updates
|
||||
Stream<PhotoViewScaleState> get outputScaleStateStream =>
|
||||
_outputScaleStateCtrl.stream;
|
||||
|
||||
/// The state value before the last change or the initial state if the state has not been changed.
|
||||
PhotoViewScaleState prevScaleState = PhotoViewScaleState.initial;
|
||||
|
||||
/// The actual state value
|
||||
PhotoViewScaleState get scaleState => _scaleStateNotifier.value;
|
||||
|
||||
/// Updates scaleState and notify all listeners (and the stream)
|
||||
set scaleState(PhotoViewScaleState newValue) {
|
||||
if (_scaleStateNotifier.value == newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevScaleState = _scaleStateNotifier.value;
|
||||
_scaleStateNotifier.value = newValue;
|
||||
}
|
||||
|
||||
/// Checks if its actual value is different than previousValue
|
||||
bool get hasChanged => prevScaleState != scaleState;
|
||||
|
||||
/// Check if is `zoomedIn` & `zoomedOut`
|
||||
bool get isZooming =>
|
||||
scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleState == PhotoViewScaleState.zoomedOut;
|
||||
|
||||
/// Resets the state to the initial value;
|
||||
void reset() {
|
||||
prevScaleState = scaleState;
|
||||
scaleState = PhotoViewScaleState.initial;
|
||||
}
|
||||
|
||||
/// Closes streams and removes eventual listeners
|
||||
void dispose() {
|
||||
_outputScaleStateCtrl.close();
|
||||
_scaleStateNotifier.dispose();
|
||||
}
|
||||
|
||||
/// Nevermind this method :D, look away
|
||||
/// Seriously: It is used to change scale state without trigging updates on the []
|
||||
void setInvisibly(PhotoViewScaleState newValue) {
|
||||
if (_scaleStateNotifier.value == newValue) {
|
||||
return;
|
||||
}
|
||||
prevScaleState = _scaleStateNotifier.value;
|
||||
_scaleStateNotifier.updateIgnoring(newValue);
|
||||
}
|
||||
|
||||
void _scaleStateChangeListener() {
|
||||
_outputScaleStateCtrl.sink.add(scaleState);
|
||||
}
|
||||
|
||||
/// Add a listener that will ignore updates made internally
|
||||
///
|
||||
/// Since it is made for internal use, it is not performatic to use more than one
|
||||
/// listener. Prefer [outputScaleStateStream]
|
||||
void addIgnorableListener(VoidCallback callback) {
|
||||
_scaleStateNotifier.addIgnorableListener(callback);
|
||||
}
|
||||
|
||||
/// Remove a listener that will ignore updates made internally
|
||||
///
|
||||
/// Since it is made for internal use, it is not performatic to use more than one
|
||||
/// listener. Prefer [outputScaleStateStream]
|
||||
void removeIgnorableListener(VoidCallback callback) {
|
||||
_scaleStateNotifier.removeIgnorableListener(callback);
|
||||
}
|
||||
}
|
||||
461
mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart
Normal file
461
mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart
Normal file
@@ -0,0 +1,461 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
|
||||
show
|
||||
PhotoViewScaleState,
|
||||
PhotoViewHeroAttributes,
|
||||
PhotoViewImageTapDownCallback,
|
||||
PhotoViewImageTapUpCallback,
|
||||
PhotoViewImageScaleEndCallback,
|
||||
PhotoViewImageDragEndCallback,
|
||||
PhotoViewImageDragStartCallback,
|
||||
PhotoViewImageDragUpdateCallback,
|
||||
ScaleStateCycle;
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_hit_corners.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart';
|
||||
|
||||
const _defaultDecoration = BoxDecoration(
|
||||
color: Color.fromRGBO(0, 0, 0, 1.0),
|
||||
);
|
||||
|
||||
/// Internal widget in which controls all animations lifecycle, core responses
|
||||
/// to user gestures, updates to the controller state and mounts the entire PhotoView Layout
|
||||
class PhotoViewCore extends StatefulWidget {
|
||||
const PhotoViewCore({
|
||||
Key? key,
|
||||
required this.imageProvider,
|
||||
required this.backgroundDecoration,
|
||||
required this.gaplessPlayback,
|
||||
required this.heroAttributes,
|
||||
required this.enableRotation,
|
||||
required this.onTapUp,
|
||||
required this.onTapDown,
|
||||
required this.onDragStart,
|
||||
required this.onDragEnd,
|
||||
required this.onDragUpdate,
|
||||
required this.onScaleEnd,
|
||||
required this.gestureDetectorBehavior,
|
||||
required this.controller,
|
||||
required this.scaleBoundaries,
|
||||
required this.scaleStateCycle,
|
||||
required this.scaleStateController,
|
||||
required this.basePosition,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.enablePanAlways,
|
||||
}) : customChild = null,
|
||||
super(key: key);
|
||||
|
||||
const PhotoViewCore.customChild({
|
||||
Key? key,
|
||||
required this.customChild,
|
||||
required this.backgroundDecoration,
|
||||
this.heroAttributes,
|
||||
required this.enableRotation,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.gestureDetectorBehavior,
|
||||
required this.controller,
|
||||
required this.scaleBoundaries,
|
||||
required this.scaleStateCycle,
|
||||
required this.scaleStateController,
|
||||
required this.basePosition,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.enablePanAlways,
|
||||
}) : imageProvider = null,
|
||||
gaplessPlayback = false,
|
||||
super(key: key);
|
||||
|
||||
final Decoration? backgroundDecoration;
|
||||
final ImageProvider? imageProvider;
|
||||
final bool? gaplessPlayback;
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
final bool enableRotation;
|
||||
final Widget? customChild;
|
||||
|
||||
final PhotoViewControllerBase controller;
|
||||
final PhotoViewScaleStateController scaleStateController;
|
||||
final ScaleBoundaries scaleBoundaries;
|
||||
final ScaleStateCycle scaleStateCycle;
|
||||
final Alignment basePosition;
|
||||
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
final bool tightMode;
|
||||
final bool disableGestures;
|
||||
final bool enablePanAlways;
|
||||
|
||||
final FilterQuality filterQuality;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return PhotoViewCoreState();
|
||||
}
|
||||
|
||||
bool get hasCustomChild => customChild != null;
|
||||
}
|
||||
|
||||
class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
with
|
||||
TickerProviderStateMixin,
|
||||
PhotoViewControllerDelegate,
|
||||
HitCornersDetector {
|
||||
Offset? _normalizedPosition;
|
||||
double? _scaleBefore;
|
||||
double? _rotationBefore;
|
||||
|
||||
late final AnimationController _scaleAnimationController;
|
||||
Animation<double>? _scaleAnimation;
|
||||
|
||||
late final AnimationController _positionAnimationController;
|
||||
Animation<Offset>? _positionAnimation;
|
||||
|
||||
late final AnimationController _rotationAnimationController =
|
||||
AnimationController(vsync: this)..addListener(handleRotationAnimation);
|
||||
Animation<double>? _rotationAnimation;
|
||||
|
||||
PhotoViewHeroAttributes? get heroAttributes => widget.heroAttributes;
|
||||
|
||||
late ScaleBoundaries cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
|
||||
void handleScaleAnimation() {
|
||||
scale = _scaleAnimation!.value;
|
||||
}
|
||||
|
||||
void handlePositionAnimate() {
|
||||
controller.position = _positionAnimation!.value;
|
||||
}
|
||||
|
||||
void handleRotationAnimation() {
|
||||
controller.rotation = _rotationAnimation!.value;
|
||||
}
|
||||
|
||||
void onScaleStart(ScaleStartDetails details) {
|
||||
_rotationBefore = controller.rotation;
|
||||
_scaleBefore = scale;
|
||||
_normalizedPosition = details.focalPoint - controller.position;
|
||||
_scaleAnimationController.stop();
|
||||
_positionAnimationController.stop();
|
||||
_rotationAnimationController.stop();
|
||||
}
|
||||
|
||||
void onScaleUpdate(ScaleUpdateDetails details) {
|
||||
final double newScale = _scaleBefore! * details.scale;
|
||||
final Offset delta = details.focalPoint - _normalizedPosition!;
|
||||
|
||||
updateScaleStateFromNewScale(newScale);
|
||||
|
||||
updateMultiple(
|
||||
scale: newScale,
|
||||
position: widget.enablePanAlways
|
||||
? delta
|
||||
: clampPosition(position: delta * details.scale),
|
||||
rotation:
|
||||
widget.enableRotation ? _rotationBefore! + details.rotation : null,
|
||||
rotationFocusPoint: widget.enableRotation ? details.focalPoint : null,
|
||||
);
|
||||
}
|
||||
|
||||
void onScaleEnd(ScaleEndDetails details) {
|
||||
final double s = scale;
|
||||
final Offset p = controller.position;
|
||||
final double maxScale = scaleBoundaries.maxScale;
|
||||
final double minScale = scaleBoundaries.minScale;
|
||||
|
||||
widget.onScaleEnd?.call(context, details, controller.value);
|
||||
|
||||
//animate back to maxScale if gesture exceeded the maxScale specified
|
||||
if (s > maxScale) {
|
||||
final double scaleComebackRatio = maxScale / s;
|
||||
animateScale(s, maxScale);
|
||||
final Offset clampedPosition = clampPosition(
|
||||
position: p * scaleComebackRatio,
|
||||
scale: maxScale,
|
||||
);
|
||||
animatePosition(p, clampedPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
//animate back to minScale if gesture fell smaller than the minScale specified
|
||||
if (s < minScale) {
|
||||
final double scaleComebackRatio = minScale / s;
|
||||
animateScale(s, minScale);
|
||||
animatePosition(
|
||||
p,
|
||||
clampPosition(
|
||||
position: p * scaleComebackRatio,
|
||||
scale: minScale,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// get magnitude from gesture velocity
|
||||
final double magnitude = details.velocity.pixelsPerSecond.distance;
|
||||
|
||||
// animate velocity only if there is no scale change and a significant magnitude
|
||||
if (_scaleBefore! / s == 1.0 && magnitude >= 400.0) {
|
||||
final Offset direction = details.velocity.pixelsPerSecond / magnitude;
|
||||
animatePosition(
|
||||
p,
|
||||
clampPosition(position: p + direction * 100.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onDoubleTap() {
|
||||
nextScaleState();
|
||||
}
|
||||
|
||||
void animateScale(double from, double to) {
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: from,
|
||||
end: to,
|
||||
).animate(_scaleAnimationController);
|
||||
_scaleAnimationController
|
||||
..value = 0.0
|
||||
..fling(velocity: 0.4);
|
||||
}
|
||||
|
||||
void animatePosition(Offset from, Offset to) {
|
||||
_positionAnimation = Tween<Offset>(begin: from, end: to)
|
||||
.animate(_positionAnimationController);
|
||||
_positionAnimationController
|
||||
..value = 0.0
|
||||
..fling(velocity: 0.4);
|
||||
}
|
||||
|
||||
void animateRotation(double from, double to) {
|
||||
_rotationAnimation = Tween<double>(begin: from, end: to)
|
||||
.animate(_rotationAnimationController);
|
||||
_rotationAnimationController
|
||||
..value = 0.0
|
||||
..fling(velocity: 0.4);
|
||||
}
|
||||
|
||||
void onAnimationStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
onAnimationStatusCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if scale is equal to initial after scale animation update
|
||||
void onAnimationStatusCompleted() {
|
||||
if (scaleStateController.scaleState != PhotoViewScaleState.initial &&
|
||||
scale == scaleBoundaries.initialScale) {
|
||||
scaleStateController.setInvisibly(PhotoViewScaleState.initial);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initDelegate();
|
||||
addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate);
|
||||
|
||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
|
||||
_scaleAnimationController = AnimationController(vsync: this)
|
||||
..addListener(handleScaleAnimation)
|
||||
..addStatusListener(onAnimationStatus);
|
||||
_positionAnimationController = AnimationController(vsync: this)
|
||||
..addListener(handlePositionAnimate);
|
||||
}
|
||||
|
||||
void animateOnScaleStateUpdate(double prevScale, double nextScale) {
|
||||
animateScale(prevScale, nextScale);
|
||||
animatePosition(controller.position, Offset.zero);
|
||||
animateRotation(controller.rotation, 0.0);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scaleAnimationController.removeStatusListener(onAnimationStatus);
|
||||
_scaleAnimationController.dispose();
|
||||
_positionAnimationController.dispose();
|
||||
_rotationAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void onTapUp(TapUpDetails details) {
|
||||
widget.onTapUp?.call(context, details, controller.value);
|
||||
}
|
||||
|
||||
void onTapDown(TapDownDetails details) {
|
||||
widget.onTapDown?.call(context, details, controller.value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check if we need a recalc on the scale
|
||||
if (widget.scaleBoundaries != cachedScaleBoundaries) {
|
||||
markNeedsScaleRecalc = true;
|
||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
}
|
||||
|
||||
return StreamBuilder(
|
||||
stream: controller.outputStateStream,
|
||||
initialData: controller.prevValue,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
AsyncSnapshot<PhotoViewControllerValue> snapshot,
|
||||
) {
|
||||
if (snapshot.hasData) {
|
||||
final PhotoViewControllerValue value = snapshot.data!;
|
||||
final useImageScale = widget.filterQuality != FilterQuality.none;
|
||||
|
||||
final computedScale = useImageScale ? 1.0 : scale;
|
||||
|
||||
final matrix = Matrix4.identity()
|
||||
..translate(value.position.dx, value.position.dy)
|
||||
..scale(computedScale)
|
||||
..rotateZ(value.rotation);
|
||||
|
||||
final Widget customChildLayout = CustomSingleChildLayout(
|
||||
delegate: _CenterWithOriginalSizeDelegate(
|
||||
scaleBoundaries.childSize,
|
||||
basePosition,
|
||||
useImageScale,
|
||||
),
|
||||
child: _buildHero(),
|
||||
);
|
||||
|
||||
final child = Container(
|
||||
constraints: widget.tightMode
|
||||
? BoxConstraints.tight(scaleBoundaries.childSize * scale)
|
||||
: null,
|
||||
decoration: widget.backgroundDecoration ?? _defaultDecoration,
|
||||
child: Center(
|
||||
child: Transform(
|
||||
transform: matrix,
|
||||
alignment: basePosition,
|
||||
child: customChildLayout,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.disableGestures) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return PhotoViewGestureDetector(
|
||||
onDoubleTap: nextScaleState,
|
||||
onScaleStart: onScaleStart,
|
||||
onScaleUpdate: onScaleUpdate,
|
||||
onScaleEnd: onScaleEnd,
|
||||
onDragStart: widget.onDragStart != null
|
||||
? (details) => widget.onDragStart!(context, details, value)
|
||||
: null,
|
||||
onDragEnd: widget.onDragEnd != null
|
||||
? (details) => widget.onDragEnd!(context, details, value)
|
||||
: null,
|
||||
onDragUpdate: widget.onDragUpdate != null
|
||||
? (details) => widget.onDragUpdate!(context, details, value)
|
||||
: null,
|
||||
hitDetector: this,
|
||||
onTapUp: widget.onTapUp != null
|
||||
? (details) => widget.onTapUp!(context, details, value)
|
||||
: null,
|
||||
onTapDown: widget.onTapDown != null
|
||||
? (details) => widget.onTapDown!(context, details, value)
|
||||
: null,
|
||||
child: child,
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHero() {
|
||||
return heroAttributes != null
|
||||
? Hero(
|
||||
tag: heroAttributes!.tag,
|
||||
createRectTween: heroAttributes!.createRectTween,
|
||||
flightShuttleBuilder: heroAttributes!.flightShuttleBuilder,
|
||||
placeholderBuilder: heroAttributes!.placeholderBuilder,
|
||||
transitionOnUserGestures: heroAttributes!.transitionOnUserGestures,
|
||||
child: _buildChild(),
|
||||
)
|
||||
: _buildChild();
|
||||
}
|
||||
|
||||
Widget _buildChild() {
|
||||
return widget.hasCustomChild
|
||||
? widget.customChild!
|
||||
: Image(
|
||||
image: widget.imageProvider!,
|
||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||
filterQuality: widget.filterQuality,
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
|
||||
const _CenterWithOriginalSizeDelegate(
|
||||
this.subjectSize,
|
||||
this.basePosition,
|
||||
this.useImageScale,
|
||||
);
|
||||
|
||||
final Size subjectSize;
|
||||
final Alignment basePosition;
|
||||
final bool useImageScale;
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
final childWidth = useImageScale ? childSize.width : subjectSize.width;
|
||||
final childHeight = useImageScale ? childSize.height : subjectSize.height;
|
||||
|
||||
final halfWidth = (size.width - childWidth) / 2;
|
||||
final halfHeight = (size.height - childHeight) / 2;
|
||||
|
||||
final double offsetX = halfWidth * (basePosition.x + 1);
|
||||
final double offsetY = halfHeight * (basePosition.y + 1);
|
||||
return Offset(offsetX, offsetY);
|
||||
}
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
return useImageScale
|
||||
? const BoxConstraints()
|
||||
: BoxConstraints.tight(subjectSize);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) {
|
||||
return oldDelegate != this;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is _CenterWithOriginalSizeDelegate &&
|
||||
runtimeType == other.runtimeType &&
|
||||
subjectSize == other.subjectSize &&
|
||||
basePosition == other.basePosition &&
|
||||
useImageScale == other.useImageScale;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
subjectSize.hashCode ^ basePosition.hashCode ^ useImageScale.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'photo_view_hit_corners.dart';
|
||||
|
||||
/// Credit to [eduribas](https://github.com/eduribas/photo_view/commit/508d9b77dafbcf88045b4a7fee737eed4064ea2c)
|
||||
/// for the gist
|
||||
class PhotoViewGestureDetector extends StatelessWidget {
|
||||
const PhotoViewGestureDetector({
|
||||
Key? key,
|
||||
this.hitDetector,
|
||||
this.onScaleStart,
|
||||
this.onScaleUpdate,
|
||||
this.onScaleEnd,
|
||||
this.onDoubleTap,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.child,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.behavior,
|
||||
}) : super(key: key);
|
||||
|
||||
final GestureDoubleTapCallback? onDoubleTap;
|
||||
final HitCornersDetector? hitDetector;
|
||||
|
||||
final GestureScaleStartCallback? onScaleStart;
|
||||
final GestureScaleUpdateCallback? onScaleUpdate;
|
||||
final GestureScaleEndCallback? onScaleEnd;
|
||||
|
||||
final GestureDragEndCallback? onDragEnd;
|
||||
final GestureDragStartCallback? onDragStart;
|
||||
final GestureDragUpdateCallback? onDragUpdate;
|
||||
|
||||
final GestureTapUpCallback? onTapUp;
|
||||
final GestureTapDownCallback? onTapDown;
|
||||
|
||||
final Widget? child;
|
||||
|
||||
final HitTestBehavior? behavior;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = PhotoViewGestureDetectorScope.of(context);
|
||||
|
||||
final Axis? axis = scope?.axis;
|
||||
final touchSlopFactor = scope?.touchSlopFactor ?? 2;
|
||||
|
||||
final Map<Type, GestureRecognizerFactory> gestures =
|
||||
<Type, GestureRecognizerFactory>{};
|
||||
|
||||
if (onTapDown != null || onTapUp != null) {
|
||||
gestures[TapGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(debugOwner: this),
|
||||
(TapGestureRecognizer instance) {
|
||||
instance
|
||||
..onTapDown = onTapDown
|
||||
..onTapUp = onTapUp;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (onDragStart != null || onDragEnd != null || onDragUpdate != null) {
|
||||
gestures[VerticalDragGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
|
||||
() => VerticalDragGestureRecognizer(debugOwner: this),
|
||||
(VerticalDragGestureRecognizer instance) {
|
||||
instance
|
||||
..onStart = onDragStart
|
||||
..onUpdate = onDragUpdate
|
||||
..onEnd = onDragEnd;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
gestures[DoubleTapGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||
() => DoubleTapGestureRecognizer(debugOwner: this),
|
||||
(DoubleTapGestureRecognizer instance) {
|
||||
instance.onDoubleTap = onDoubleTap;
|
||||
},
|
||||
);
|
||||
|
||||
gestures[PhotoViewGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<PhotoViewGestureRecognizer>(
|
||||
() => PhotoViewGestureRecognizer(
|
||||
hitDetector: hitDetector,
|
||||
debugOwner: this,
|
||||
validateAxis: axis,
|
||||
touchSlopFactor: touchSlopFactor,
|
||||
),
|
||||
(PhotoViewGestureRecognizer instance) {
|
||||
instance
|
||||
..onStart = onScaleStart
|
||||
..onUpdate = onScaleUpdate
|
||||
..onEnd = onScaleEnd;
|
||||
},
|
||||
);
|
||||
|
||||
return RawGestureDetector(
|
||||
behavior: behavior,
|
||||
gestures: gestures,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PhotoViewGestureRecognizer extends ScaleGestureRecognizer {
|
||||
PhotoViewGestureRecognizer({
|
||||
this.hitDetector,
|
||||
Object? debugOwner,
|
||||
this.validateAxis,
|
||||
this.touchSlopFactor = 1,
|
||||
PointerDeviceKind? kind,
|
||||
}) : super(debugOwner: debugOwner, supportedDevices: null);
|
||||
final HitCornersDetector? hitDetector;
|
||||
final Axis? validateAxis;
|
||||
final double touchSlopFactor;
|
||||
|
||||
Map<int, Offset> _pointerLocations = <int, Offset>{};
|
||||
|
||||
Offset? _initialFocalPoint;
|
||||
Offset? _currentFocalPoint;
|
||||
double? _initialSpan;
|
||||
double? _currentSpan;
|
||||
|
||||
bool ready = true;
|
||||
|
||||
@override
|
||||
void addAllowedPointer(PointerDownEvent event) {
|
||||
if (ready) {
|
||||
ready = false;
|
||||
_pointerLocations = <int, Offset>{};
|
||||
}
|
||||
super.addAllowedPointer(event);
|
||||
}
|
||||
|
||||
@override
|
||||
void didStopTrackingLastPointer(int pointer) {
|
||||
ready = true;
|
||||
super.didStopTrackingLastPointer(pointer);
|
||||
}
|
||||
|
||||
@override
|
||||
void handleEvent(PointerEvent event) {
|
||||
if (validateAxis != null) {
|
||||
bool didChangeConfiguration = false;
|
||||
if (event is PointerMoveEvent) {
|
||||
if (!event.synthesized) {
|
||||
_pointerLocations[event.pointer] = event.position;
|
||||
}
|
||||
} else if (event is PointerDownEvent) {
|
||||
_pointerLocations[event.pointer] = event.position;
|
||||
didChangeConfiguration = true;
|
||||
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||
_pointerLocations.remove(event.pointer);
|
||||
didChangeConfiguration = true;
|
||||
}
|
||||
|
||||
_updateDistances();
|
||||
|
||||
if (didChangeConfiguration) {
|
||||
// cf super._reconfigure
|
||||
_initialFocalPoint = _currentFocalPoint;
|
||||
_initialSpan = _currentSpan;
|
||||
}
|
||||
|
||||
_decideIfWeAcceptEvent(event);
|
||||
}
|
||||
super.handleEvent(event);
|
||||
}
|
||||
|
||||
void _updateDistances() {
|
||||
// cf super._update
|
||||
final int count = _pointerLocations.keys.length;
|
||||
|
||||
// Compute the focal point
|
||||
Offset focalPoint = Offset.zero;
|
||||
for (final int pointer in _pointerLocations.keys) {
|
||||
focalPoint += _pointerLocations[pointer]!;
|
||||
}
|
||||
_currentFocalPoint =
|
||||
count > 0 ? focalPoint / count.toDouble() : Offset.zero;
|
||||
|
||||
// Span is the average deviation from focal point. Horizontal and vertical
|
||||
// spans are the average deviations from the focal point's horizontal and
|
||||
// vertical coordinates, respectively.
|
||||
double totalDeviation = 0.0;
|
||||
for (final int pointer in _pointerLocations.keys) {
|
||||
totalDeviation +=
|
||||
(_currentFocalPoint! - _pointerLocations[pointer]!).distance;
|
||||
}
|
||||
_currentSpan = count > 0 ? totalDeviation / count : 0.0;
|
||||
}
|
||||
|
||||
void _decideIfWeAcceptEvent(PointerEvent event) {
|
||||
final move = _initialFocalPoint! - _currentFocalPoint!;
|
||||
final bool shouldMove = validateAxis == Axis.vertical
|
||||
? hitDetector!.shouldMove(move, Axis.vertical)
|
||||
: hitDetector!.shouldMove(move, Axis.horizontal);
|
||||
if (shouldMove || _pointerLocations.keys.length > 1) {
|
||||
final double spanDelta = (_currentSpan! - _initialSpan!).abs();
|
||||
final double focalPointDelta =
|
||||
(_currentFocalPoint! - _initialFocalPoint!).distance;
|
||||
// warning: do not compare `focalPointDelta` to `kPanSlop`
|
||||
// `ScaleGestureRecognizer` uses `kPanSlop`, but `HorizontalDragGestureRecognizer` uses `kTouchSlop`
|
||||
// and PhotoView recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView`
|
||||
// setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0`
|
||||
// setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView`
|
||||
if (spanDelta > kScaleSlop ||
|
||||
focalPointDelta > kTouchSlop * touchSlopFactor) {
|
||||
acceptGesture(event.pointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An [InheritedWidget] responsible to give a axis aware scope to [PhotoViewGestureRecognizer].
|
||||
///
|
||||
/// When using this, PhotoView will test if the content zoomed has hit edge every time user pinches,
|
||||
/// if so, it will let parent gesture detectors win the gesture arena
|
||||
///
|
||||
/// Useful when placing PhotoView inside a gesture sensitive context,
|
||||
/// such as [PageView], [Dismissible], [BottomSheet].
|
||||
///
|
||||
/// Usage example:
|
||||
/// ```
|
||||
/// PhotoViewGestureDetectorScope(
|
||||
/// axis: Axis.vertical,
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/pudim.jpg"),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
class PhotoViewGestureDetectorScope extends InheritedWidget {
|
||||
const PhotoViewGestureDetectorScope({
|
||||
super.key,
|
||||
this.axis,
|
||||
this.touchSlopFactor = .2,
|
||||
required Widget child,
|
||||
}) : super(child: child);
|
||||
|
||||
static PhotoViewGestureDetectorScope? of(BuildContext context) {
|
||||
final PhotoViewGestureDetectorScope? scope = context
|
||||
.dependOnInheritedWidgetOfExactType<PhotoViewGestureDetectorScope>();
|
||||
return scope;
|
||||
}
|
||||
|
||||
final Axis? axis;
|
||||
|
||||
// in [0, 1[
|
||||
// 0: most reactive but will not let tap recognizers accept gestures
|
||||
// <1: less reactive but gives the most leeway to other recognizers
|
||||
// 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree
|
||||
final double touchSlopFactor;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(PhotoViewGestureDetectorScope oldWidget) {
|
||||
return axis != oldWidget.axis && touchSlopFactor != oldWidget.touchSlopFactor;
|
||||
}
|
||||
}
|
||||
|
||||
// `PageView` contains a `Scrollable` which sets up a `HorizontalDragGestureRecognizer`
|
||||
// this recognizer will win in the gesture arena when the drag distance reaches `kTouchSlop`
|
||||
// we cannot change that, but we can prevent the scrollable from panning until this threshold is reached
|
||||
// and let other recognizers accept the gesture instead
|
||||
class PhotoViewPageViewScrollPhysics extends ScrollPhysics {
|
||||
const PhotoViewPageViewScrollPhysics({
|
||||
this.touchSlopFactor = 0.1,
|
||||
ScrollPhysics? parent,
|
||||
}) : super(parent: parent);
|
||||
|
||||
|
||||
// in [0, 1]
|
||||
// 0: most reactive but will not let PhotoView recognizers accept gestures
|
||||
// 1: less reactive but gives the most leeway to PhotoView recognizers
|
||||
final double touchSlopFactor;
|
||||
|
||||
|
||||
@override
|
||||
PhotoViewPageViewScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return PhotoViewPageViewScrollPhysics(
|
||||
touchSlopFactor: touchSlopFactor,
|
||||
parent: buildParent(ancestor),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
double get dragStartDistanceMotionThreshold => kTouchSlop * touchSlopFactor;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart'
|
||||
show PhotoViewControllerDelegate;
|
||||
|
||||
mixin HitCornersDetector on PhotoViewControllerDelegate {
|
||||
HitCorners _hitCornersX() {
|
||||
final double childWidth = scaleBoundaries.childSize.width * scale;
|
||||
final double screenWidth = scaleBoundaries.outerSize.width;
|
||||
if (screenWidth >= childWidth) {
|
||||
return const HitCorners(true, true);
|
||||
}
|
||||
final x = -position.dx;
|
||||
final cornersX = this.cornersX();
|
||||
return HitCorners(x <= cornersX.min, x >= cornersX.max);
|
||||
}
|
||||
|
||||
HitCorners _hitCornersY() {
|
||||
final double childHeight = scaleBoundaries.childSize.height * scale;
|
||||
final double screenHeight = scaleBoundaries.outerSize.height;
|
||||
if (screenHeight >= childHeight) {
|
||||
return const HitCorners(true, true);
|
||||
}
|
||||
final y = -position.dy;
|
||||
final cornersY = this.cornersY();
|
||||
return HitCorners(y <= cornersY.min, y >= cornersY.max);
|
||||
}
|
||||
|
||||
bool _shouldMoveAxis(HitCorners hitCorners, double mainAxisMove, double crossAxisMove) {
|
||||
if (mainAxisMove == 0) {
|
||||
return false;
|
||||
}
|
||||
if (!hitCorners.hasHitAny) {
|
||||
return true;
|
||||
}
|
||||
final axisBlocked = hitCorners.hasHitBoth ||
|
||||
(hitCorners.hasHitMax ? mainAxisMove > 0 : mainAxisMove < 0);
|
||||
if (axisBlocked) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _shouldMoveX(Offset move) {
|
||||
final hitCornersX = _hitCornersX();
|
||||
final mainAxisMove = move.dx;
|
||||
final crossAxisMove = move.dy;
|
||||
|
||||
return _shouldMoveAxis(hitCornersX, mainAxisMove, crossAxisMove);
|
||||
}
|
||||
|
||||
bool _shouldMoveY(Offset move) {
|
||||
final hitCornersY = _hitCornersY();
|
||||
final mainAxisMove = move.dy;
|
||||
final crossAxisMove = move.dx;
|
||||
|
||||
return _shouldMoveAxis(hitCornersY, mainAxisMove, crossAxisMove);
|
||||
}
|
||||
|
||||
bool shouldMove(Offset move, Axis mainAxis) {
|
||||
if (mainAxis == Axis.vertical) {
|
||||
return _shouldMoveY(move);
|
||||
}
|
||||
return _shouldMoveX(move);
|
||||
}
|
||||
}
|
||||
|
||||
class HitCorners {
|
||||
const HitCorners(this.hasHitMin, this.hasHitMax);
|
||||
|
||||
final bool hasHitMin;
|
||||
final bool hasHitMax;
|
||||
|
||||
bool get hasHitAny => hasHitMin || hasHitMax;
|
||||
|
||||
bool get hasHitBoth => hasHitMin && hasHitMax;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/// A class that work as a enum. It overloads the operator `*` saving the double as a multiplier.
|
||||
///
|
||||
/// ```
|
||||
/// PhotoViewComputedScale.contained * 2
|
||||
/// ```
|
||||
///
|
||||
class PhotoViewComputedScale {
|
||||
const PhotoViewComputedScale._internal(this._value, [this.multiplier = 1.0]);
|
||||
|
||||
final String _value;
|
||||
final double multiplier;
|
||||
|
||||
@override
|
||||
String toString() => 'Enum.$_value';
|
||||
|
||||
static const contained = PhotoViewComputedScale._internal('contained');
|
||||
static const covered = PhotoViewComputedScale._internal('covered');
|
||||
|
||||
PhotoViewComputedScale operator *(double multiplier) {
|
||||
return PhotoViewComputedScale._internal(_value, multiplier);
|
||||
}
|
||||
|
||||
PhotoViewComputedScale operator /(double divider) {
|
||||
return PhotoViewComputedScale._internal(_value, 1 / divider);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PhotoViewComputedScale &&
|
||||
runtimeType == other.runtimeType &&
|
||||
_value == other._value;
|
||||
|
||||
@override
|
||||
int get hashCode => _value.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PhotoViewDefaultError extends StatelessWidget {
|
||||
const PhotoViewDefaultError({Key? key, required this.decoration})
|
||||
: super(key: key);
|
||||
|
||||
final BoxDecoration decoration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: decoration,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.broken_image,
|
||||
color: Colors.grey[400],
|
||||
size: 40.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PhotoViewDefaultLoading extends StatelessWidget {
|
||||
const PhotoViewDefaultLoading({Key? key, this.event}) : super(key: key);
|
||||
|
||||
final ImageChunkEvent? event;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expectedBytes = event?.expectedTotalBytes;
|
||||
final loadedBytes = event?.cumulativeBytesLoaded;
|
||||
final value = loadedBytes != null && expectedBytes != null
|
||||
? loadedBytes / expectedBytes
|
||||
: null;
|
||||
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20.0,
|
||||
height: 20.0,
|
||||
child: CircularProgressIndicator(value: value),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/// A way to represent the step of the "doubletap gesture cycle" in which PhotoView is.
|
||||
enum PhotoViewScaleState {
|
||||
initial,
|
||||
covering,
|
||||
originalSize,
|
||||
zoomedIn,
|
||||
zoomedOut;
|
||||
|
||||
bool get isScaleStateZooming =>
|
||||
this == PhotoViewScaleState.zoomedIn ||
|
||||
this == PhotoViewScaleState.zoomedOut;
|
||||
}
|
||||
327
mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart
Normal file
327
mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart
Normal file
@@ -0,0 +1,327 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../photo_view.dart';
|
||||
import 'core/photo_view_core.dart';
|
||||
import 'photo_view_default_widgets.dart';
|
||||
import 'utils/photo_view_utils.dart';
|
||||
|
||||
class ImageWrapper extends StatefulWidget {
|
||||
const ImageWrapper({
|
||||
Key? key,
|
||||
required this.imageProvider,
|
||||
required this.loadingBuilder,
|
||||
required this.backgroundDecoration,
|
||||
required this.gaplessPlayback,
|
||||
required this.heroAttributes,
|
||||
required this.scaleStateChangedCallback,
|
||||
required this.enableRotation,
|
||||
required this.controller,
|
||||
required this.scaleStateController,
|
||||
required this.maxScale,
|
||||
required this.minScale,
|
||||
required this.initialScale,
|
||||
required this.basePosition,
|
||||
required this.scaleStateCycle,
|
||||
required this.onTapUp,
|
||||
required this.onTapDown,
|
||||
required this.onDragStart,
|
||||
required this.onDragEnd,
|
||||
required this.onDragUpdate,
|
||||
required this.onScaleEnd,
|
||||
required this.outerSize,
|
||||
required this.gestureDetectorBehavior,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.errorBuilder,
|
||||
required this.enablePanAlways,
|
||||
}) : super(key: key);
|
||||
|
||||
final ImageProvider imageProvider;
|
||||
final LoadingBuilder? loadingBuilder;
|
||||
final ImageErrorWidgetBuilder? errorBuilder;
|
||||
final BoxDecoration backgroundDecoration;
|
||||
final bool gaplessPlayback;
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
final bool enableRotation;
|
||||
final dynamic maxScale;
|
||||
final dynamic minScale;
|
||||
final dynamic initialScale;
|
||||
final PhotoViewControllerBase controller;
|
||||
final PhotoViewScaleStateController scaleStateController;
|
||||
final Alignment? basePosition;
|
||||
final ScaleStateCycle? scaleStateCycle;
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
final Size outerSize;
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
final bool? tightMode;
|
||||
final FilterQuality? filterQuality;
|
||||
final bool? disableGestures;
|
||||
final bool? enablePanAlways;
|
||||
|
||||
@override
|
||||
createState() => _ImageWrapperState();
|
||||
}
|
||||
|
||||
class _ImageWrapperState extends State<ImageWrapper> {
|
||||
ImageStreamListener? _imageStreamListener;
|
||||
ImageStream? _imageStream;
|
||||
ImageChunkEvent? _loadingProgress;
|
||||
ImageInfo? _imageInfo;
|
||||
bool _loading = true;
|
||||
Size? _imageSize;
|
||||
Object? _lastException;
|
||||
StackTrace? _lastStack;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_stopImageStream();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_resolveImage();
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ImageWrapper oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.imageProvider != oldWidget.imageProvider) {
|
||||
_resolveImage();
|
||||
}
|
||||
}
|
||||
|
||||
// retrieve image from the provider
|
||||
void _resolveImage() {
|
||||
final ImageStream newStream = widget.imageProvider.resolve(
|
||||
const ImageConfiguration(),
|
||||
);
|
||||
_updateSourceStream(newStream);
|
||||
}
|
||||
|
||||
ImageStreamListener _getOrCreateListener() {
|
||||
void handleImageChunk(ImageChunkEvent event) {
|
||||
setState(() {
|
||||
_loadingProgress = event;
|
||||
_lastException = null;
|
||||
});
|
||||
}
|
||||
|
||||
void handleImageFrame(ImageInfo info, bool synchronousCall) {
|
||||
setupCB() {
|
||||
_imageSize = Size(
|
||||
info.image.width.toDouble(),
|
||||
info.image.height.toDouble(),
|
||||
);
|
||||
_loading = false;
|
||||
_imageInfo = _imageInfo;
|
||||
|
||||
_loadingProgress = null;
|
||||
_lastException = null;
|
||||
_lastStack = null;
|
||||
}
|
||||
synchronousCall ? setupCB() : setState(setupCB);
|
||||
}
|
||||
|
||||
void handleError(dynamic error, StackTrace? stackTrace) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_lastException = error;
|
||||
_lastStack = stackTrace;
|
||||
});
|
||||
assert(() {
|
||||
if (widget.errorBuilder == null) {
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
}
|
||||
|
||||
_imageStreamListener = ImageStreamListener(
|
||||
handleImageFrame,
|
||||
onChunk: handleImageChunk,
|
||||
onError: handleError,
|
||||
);
|
||||
|
||||
return _imageStreamListener!;
|
||||
}
|
||||
|
||||
void _updateSourceStream(ImageStream newStream) {
|
||||
if (_imageStream?.key == newStream.key) {
|
||||
return;
|
||||
}
|
||||
_imageStream?.removeListener(_imageStreamListener!);
|
||||
_imageStream = newStream;
|
||||
_imageStream!.addListener(_getOrCreateListener());
|
||||
}
|
||||
|
||||
void _stopImageStream() {
|
||||
_imageStream?.removeListener(_imageStreamListener!);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return _buildLoading(context);
|
||||
}
|
||||
|
||||
if (_lastException != null) {
|
||||
return _buildError(context);
|
||||
}
|
||||
|
||||
final scaleBoundaries = ScaleBoundaries(
|
||||
widget.minScale ?? 0.0,
|
||||
widget.maxScale ?? double.infinity,
|
||||
widget.initialScale ?? PhotoViewComputedScale.contained,
|
||||
widget.outerSize,
|
||||
_imageSize!,
|
||||
);
|
||||
|
||||
return PhotoViewCore(
|
||||
imageProvider: widget.imageProvider,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
enableRotation: widget.enableRotation,
|
||||
heroAttributes: widget.heroAttributes,
|
||||
basePosition: widget.basePosition ?? Alignment.center,
|
||||
controller: widget.controller,
|
||||
scaleStateController: widget.scaleStateController,
|
||||
scaleStateCycle: widget.scaleStateCycle ?? defaultScaleStateCycle,
|
||||
scaleBoundaries: scaleBoundaries,
|
||||
onTapUp: widget.onTapUp,
|
||||
onTapDown: widget.onTapDown,
|
||||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
gestureDetectorBehavior: widget.gestureDetectorBehavior,
|
||||
tightMode: widget.tightMode ?? false,
|
||||
filterQuality: widget.filterQuality ?? FilterQuality.none,
|
||||
disableGestures: widget.disableGestures ?? false,
|
||||
enablePanAlways: widget.enablePanAlways ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoading(BuildContext context) {
|
||||
if (widget.loadingBuilder != null) {
|
||||
return widget.loadingBuilder!(context, _loadingProgress);
|
||||
}
|
||||
|
||||
return PhotoViewDefaultLoading(
|
||||
event: _loadingProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(
|
||||
BuildContext context,
|
||||
) {
|
||||
if (widget.errorBuilder != null) {
|
||||
return widget.errorBuilder!(context, _lastException!, _lastStack);
|
||||
}
|
||||
return PhotoViewDefaultError(
|
||||
decoration: widget.backgroundDecoration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomChildWrapper extends StatelessWidget {
|
||||
const CustomChildWrapper({
|
||||
Key? key,
|
||||
this.child,
|
||||
required this.childSize,
|
||||
required this.backgroundDecoration,
|
||||
this.heroAttributes,
|
||||
this.scaleStateChangedCallback,
|
||||
required this.enableRotation,
|
||||
required this.controller,
|
||||
required this.scaleStateController,
|
||||
required this.maxScale,
|
||||
required this.minScale,
|
||||
required this.initialScale,
|
||||
required this.basePosition,
|
||||
required this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
required this.outerSize,
|
||||
this.gestureDetectorBehavior,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.enablePanAlways,
|
||||
}) : super(key: key);
|
||||
|
||||
final Widget? child;
|
||||
final Size? childSize;
|
||||
final Decoration backgroundDecoration;
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
final bool enableRotation;
|
||||
|
||||
final PhotoViewControllerBase controller;
|
||||
final PhotoViewScaleStateController scaleStateController;
|
||||
|
||||
final dynamic maxScale;
|
||||
final dynamic minScale;
|
||||
final dynamic initialScale;
|
||||
|
||||
final Alignment? basePosition;
|
||||
final ScaleStateCycle? scaleStateCycle;
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
final Size outerSize;
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
final bool? tightMode;
|
||||
final FilterQuality? filterQuality;
|
||||
final bool? disableGestures;
|
||||
final bool? enablePanAlways;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scaleBoundaries = ScaleBoundaries(
|
||||
minScale ?? 0.0,
|
||||
maxScale ?? double.infinity,
|
||||
initialScale ?? PhotoViewComputedScale.contained,
|
||||
outerSize,
|
||||
childSize ?? outerSize,
|
||||
);
|
||||
|
||||
return PhotoViewCore.customChild(
|
||||
customChild: child,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
enableRotation: enableRotation,
|
||||
heroAttributes: heroAttributes,
|
||||
controller: controller,
|
||||
scaleStateController: scaleStateController,
|
||||
scaleStateCycle: scaleStateCycle ?? defaultScaleStateCycle,
|
||||
basePosition: basePosition ?? Alignment.center,
|
||||
scaleBoundaries: scaleBoundaries,
|
||||
onTapUp: onTapUp,
|
||||
onTapDown: onTapDown,
|
||||
onDragStart: onDragStart,
|
||||
onDragEnd: onDragEnd,
|
||||
onDragUpdate: onDragUpdate,
|
||||
onScaleEnd: onScaleEnd,
|
||||
gestureDetectorBehavior: gestureDetectorBehavior,
|
||||
tightMode: tightMode ?? false,
|
||||
filterQuality: filterQuality ?? FilterQuality.none,
|
||||
disableGestures: disableGestures ?? false,
|
||||
enablePanAlways: enablePanAlways ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// A [ChangeNotifier] that has a second collection of listeners: the ignorable ones
|
||||
///
|
||||
/// Those listeners will be fired when [notifyListeners] fires and will be ignored
|
||||
/// when [notifySomeListeners] fires.
|
||||
///
|
||||
/// The common collection of listeners inherited from [ChangeNotifier] will be fired
|
||||
/// every time.
|
||||
class IgnorableChangeNotifier extends ChangeNotifier {
|
||||
ObserverList<VoidCallback>? _ignorableListeners =
|
||||
ObserverList<VoidCallback>();
|
||||
|
||||
bool _debugAssertNotDisposed() {
|
||||
assert(() {
|
||||
if (_ignorableListeners == null) {
|
||||
AssertionError([
|
||||
'A $runtimeType was used after being disposed.',
|
||||
'Once you have called dispose() on a $runtimeType, it can no longer be used.'
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get hasListeners {
|
||||
return super.hasListeners || (_ignorableListeners?.isNotEmpty ?? false);
|
||||
}
|
||||
|
||||
void addIgnorableListener(listener) {
|
||||
assert(_debugAssertNotDisposed());
|
||||
_ignorableListeners!.add(listener);
|
||||
}
|
||||
|
||||
void removeIgnorableListener(listener) {
|
||||
assert(_debugAssertNotDisposed());
|
||||
_ignorableListeners!.remove(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ignorableListeners = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@protected
|
||||
@override
|
||||
@visibleForTesting
|
||||
void notifyListeners() {
|
||||
super.notifyListeners();
|
||||
if (_ignorableListeners != null) {
|
||||
final List<VoidCallback> localListeners =
|
||||
List<VoidCallback>.from(_ignorableListeners!);
|
||||
for (VoidCallback listener in localListeners) {
|
||||
try {
|
||||
if (_ignorableListeners!.contains(listener)) {
|
||||
listener();
|
||||
}
|
||||
} catch (exception, stack) {
|
||||
FlutterError.reportError(
|
||||
FlutterErrorDetails(
|
||||
exception: exception,
|
||||
stack: stack,
|
||||
library: 'Photoview library',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ignores the ignoreables
|
||||
@protected
|
||||
void notifySomeListeners() {
|
||||
super.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Just like [ValueNotifier] except it extends [IgnorableChangeNotifier] which has
|
||||
/// listeners that wont fire when [updateIgnoring] is called.
|
||||
class IgnorableValueNotifier<T> extends IgnorableChangeNotifier
|
||||
implements ValueListenable<T> {
|
||||
IgnorableValueNotifier(this._value);
|
||||
|
||||
@override
|
||||
T get value => _value;
|
||||
T _value;
|
||||
|
||||
set value(T newValue) {
|
||||
if (_value == newValue) {
|
||||
return;
|
||||
}
|
||||
_value = newValue;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateIgnoring(T newValue) {
|
||||
if (_value == newValue) {
|
||||
return;
|
||||
}
|
||||
_value = newValue;
|
||||
notifySomeListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '${describeIdentity(this)}($value)';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Data class that holds the attributes that are going to be passed to
|
||||
/// [PhotoViewImageWrapper]'s [Hero].
|
||||
class PhotoViewHeroAttributes {
|
||||
const PhotoViewHeroAttributes({
|
||||
required this.tag,
|
||||
this.createRectTween,
|
||||
this.flightShuttleBuilder,
|
||||
this.placeholderBuilder,
|
||||
this.transitionOnUserGestures = false,
|
||||
});
|
||||
|
||||
/// Mirror to [Hero.tag]
|
||||
final Object tag;
|
||||
|
||||
/// Mirror to [Hero.createRectTween]
|
||||
final CreateRectTween? createRectTween;
|
||||
|
||||
/// Mirror to [Hero.flightShuttleBuilder]
|
||||
final HeroFlightShuttleBuilder? flightShuttleBuilder;
|
||||
|
||||
/// Mirror to [Hero.placeholderBuilder]
|
||||
final HeroPlaceholderBuilder? placeholderBuilder;
|
||||
|
||||
/// Mirror to [Hero.transitionOnUserGestures]
|
||||
final bool transitionOnUserGestures;
|
||||
}
|
||||
145
mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart
Normal file
145
mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart
Normal file
@@ -0,0 +1,145 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show Size;
|
||||
|
||||
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';
|
||||
|
||||
/// Given a [PhotoViewScaleState], returns a scale value considering [scaleBoundaries].
|
||||
double getScaleForScaleState(
|
||||
PhotoViewScaleState scaleState,
|
||||
ScaleBoundaries scaleBoundaries,
|
||||
) {
|
||||
switch (scaleState) {
|
||||
case PhotoViewScaleState.initial:
|
||||
case PhotoViewScaleState.zoomedIn:
|
||||
case PhotoViewScaleState.zoomedOut:
|
||||
return _clampSize(scaleBoundaries.initialScale, scaleBoundaries);
|
||||
case PhotoViewScaleState.covering:
|
||||
return _clampSize(
|
||||
_scaleForCovering(
|
||||
scaleBoundaries.outerSize,
|
||||
scaleBoundaries.childSize,
|
||||
),
|
||||
scaleBoundaries,
|
||||
);
|
||||
case PhotoViewScaleState.originalSize:
|
||||
return _clampSize(1.0, scaleBoundaries);
|
||||
// Will never be reached
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal class to wraps custom scale boundaries (min, max and initial)
|
||||
/// Also, stores values regarding the two sizes: the container and teh child.
|
||||
class ScaleBoundaries {
|
||||
const ScaleBoundaries(
|
||||
this._minScale,
|
||||
this._maxScale,
|
||||
this._initialScale,
|
||||
this.outerSize,
|
||||
this.childSize,
|
||||
);
|
||||
|
||||
final dynamic _minScale;
|
||||
final dynamic _maxScale;
|
||||
final dynamic _initialScale;
|
||||
final Size outerSize;
|
||||
final Size childSize;
|
||||
|
||||
double get minScale {
|
||||
assert(_minScale is double || _minScale is PhotoViewComputedScale);
|
||||
if (_minScale == PhotoViewComputedScale.contained) {
|
||||
return _scaleForContained(outerSize, childSize) *
|
||||
(_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
|
||||
}
|
||||
if (_minScale == PhotoViewComputedScale.covered) {
|
||||
return _scaleForCovering(outerSize, childSize) *
|
||||
(_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
|
||||
}
|
||||
assert(_minScale >= 0.0);
|
||||
return _minScale;
|
||||
}
|
||||
|
||||
double get maxScale {
|
||||
assert(_maxScale is double || _maxScale is PhotoViewComputedScale);
|
||||
if (_maxScale == PhotoViewComputedScale.contained) {
|
||||
return (_scaleForContained(outerSize, childSize) *
|
||||
(_maxScale as PhotoViewComputedScale) // ignore: avoid_as
|
||||
.multiplier)
|
||||
.clamp(minScale, double.infinity);
|
||||
}
|
||||
if (_maxScale == PhotoViewComputedScale.covered) {
|
||||
return (_scaleForCovering(outerSize, childSize) *
|
||||
(_maxScale as PhotoViewComputedScale) // ignore: avoid_as
|
||||
.multiplier)
|
||||
.clamp(minScale, double.infinity);
|
||||
}
|
||||
return _maxScale.clamp(minScale, double.infinity);
|
||||
}
|
||||
|
||||
double get initialScale {
|
||||
assert(_initialScale is double || _initialScale is PhotoViewComputedScale);
|
||||
if (_initialScale == PhotoViewComputedScale.contained) {
|
||||
return _scaleForContained(outerSize, childSize) *
|
||||
(_initialScale as PhotoViewComputedScale) // ignore: avoid_as
|
||||
.multiplier;
|
||||
}
|
||||
if (_initialScale == PhotoViewComputedScale.covered) {
|
||||
return _scaleForCovering(outerSize, childSize) *
|
||||
(_initialScale as PhotoViewComputedScale) // ignore: avoid_as
|
||||
.multiplier;
|
||||
}
|
||||
return _initialScale.clamp(minScale, maxScale);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ScaleBoundaries &&
|
||||
runtimeType == other.runtimeType &&
|
||||
_minScale == other._minScale &&
|
||||
_maxScale == other._maxScale &&
|
||||
_initialScale == other._initialScale &&
|
||||
outerSize == other.outerSize &&
|
||||
childSize == other.childSize;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
_minScale.hashCode ^
|
||||
_maxScale.hashCode ^
|
||||
_initialScale.hashCode ^
|
||||
outerSize.hashCode ^
|
||||
childSize.hashCode;
|
||||
}
|
||||
|
||||
double _scaleForContained(Size size, Size childSize) {
|
||||
final double imageWidth = childSize.width;
|
||||
final double imageHeight = childSize.height;
|
||||
|
||||
final double screenWidth = size.width;
|
||||
final double screenHeight = size.height;
|
||||
|
||||
return math.min(screenWidth / imageWidth, screenHeight / imageHeight);
|
||||
}
|
||||
|
||||
double _scaleForCovering(Size size, Size childSize) {
|
||||
final double imageWidth = childSize.width;
|
||||
final double imageHeight = childSize.height;
|
||||
|
||||
final double screenWidth = size.width;
|
||||
final double screenHeight = size.height;
|
||||
|
||||
return math.max(screenWidth / imageWidth, screenHeight / imageHeight);
|
||||
}
|
||||
|
||||
double _clampSize(double size, ScaleBoundaries scaleBoundaries) {
|
||||
return size.clamp(scaleBoundaries.minScale, scaleBoundaries.maxScale);
|
||||
}
|
||||
|
||||
/// Simple class to store a min and a max value
|
||||
class CornersRange {
|
||||
const CornersRange(this.min, this.max);
|
||||
final double min;
|
||||
final double max;
|
||||
}
|
||||
2
mobile/openapi/README.md
generated
2
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.42.0
|
||||
- API version: 1.43.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
## Requirements
|
||||
|
||||
1
mobile/openapi/doc/AssetFileUploadResponseDto.md
generated
1
mobile/openapi/doc/AssetFileUploadResponseDto.md
generated
@@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**id** | **String** | |
|
||||
**duplicate** | **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)
|
||||
|
||||
|
||||
94
mobile/openapi/lib/model/album_response_dto.dart
generated
94
mobile/openapi/lib/model/album_response_dto.dart
generated
@@ -43,48 +43,51 @@ class AlbumResponseDto {
|
||||
List<AssetResponseDto> assets;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
|
||||
other.assetCount == assetCount &&
|
||||
other.id == id &&
|
||||
other.ownerId == ownerId &&
|
||||
other.albumName == albumName &&
|
||||
other.createdAt == createdAt &&
|
||||
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
||||
other.shared == shared &&
|
||||
other.sharedUsers == sharedUsers &&
|
||||
other.assets == assets;
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AlbumResponseDto &&
|
||||
other.assetCount == assetCount &&
|
||||
other.id == id &&
|
||||
other.ownerId == ownerId &&
|
||||
other.albumName == albumName &&
|
||||
other.createdAt == createdAt &&
|
||||
other.albumThumbnailAssetId == albumThumbnailAssetId &&
|
||||
other.shared == shared &&
|
||||
other.sharedUsers == sharedUsers &&
|
||||
other.assets == assets;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetCount.hashCode) +
|
||||
(id.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(albumName.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
|
||||
(shared.hashCode) +
|
||||
(sharedUsers.hashCode) +
|
||||
(assets.hashCode);
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetCount.hashCode) +
|
||||
(id.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(albumName.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
|
||||
(shared.hashCode) +
|
||||
(sharedUsers.hashCode) +
|
||||
(assets.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
|
||||
String toString() =>
|
||||
'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assetCount'] = this.assetCount;
|
||||
json[r'id'] = this.id;
|
||||
json[r'ownerId'] = this.ownerId;
|
||||
json[r'albumName'] = this.albumName;
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
json[r'assetCount'] = this.assetCount;
|
||||
json[r'id'] = this.id;
|
||||
json[r'ownerId'] = this.ownerId;
|
||||
json[r'albumName'] = this.albumName;
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
if (this.albumThumbnailAssetId != null) {
|
||||
json[r'albumThumbnailAssetId'] = this.albumThumbnailAssetId;
|
||||
} else {
|
||||
// json[r'albumThumbnailAssetId'] = null;
|
||||
}
|
||||
json[r'shared'] = this.shared;
|
||||
json[r'sharedUsers'] = this.sharedUsers;
|
||||
json[r'assets'] = this.assets;
|
||||
json[r'shared'] = this.shared;
|
||||
json[r'sharedUsers'] = this.sharedUsers;
|
||||
json[r'assets'] = this.assets;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -98,13 +101,13 @@ class AlbumResponseDto {
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
// assert(() {
|
||||
// requiredKeys.forEach((key) {
|
||||
// assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
|
||||
// assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
|
||||
// });
|
||||
// return true;
|
||||
// }());
|
||||
|
||||
return AlbumResponseDto(
|
||||
assetCount: mapValueOfType<int>(json, r'assetCount')!,
|
||||
@@ -112,7 +115,8 @@ class AlbumResponseDto {
|
||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
albumName: mapValueOfType<String>(json, r'albumName')!,
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
|
||||
albumThumbnailAssetId:
|
||||
mapValueOfType<String>(json, r'albumThumbnailAssetId'),
|
||||
shared: mapValueOfType<bool>(json, r'shared')!,
|
||||
sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!,
|
||||
assets: AssetResponseDto.listFromJson(json[r'assets'])!,
|
||||
@@ -121,7 +125,10 @@ class AlbumResponseDto {
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AlbumResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
static List<AlbumResponseDto>? listFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final result = <AlbumResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
@@ -149,12 +156,18 @@ class AlbumResponseDto {
|
||||
}
|
||||
|
||||
// maps a json object with a list of AlbumResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AlbumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
static Map<String, List<AlbumResponseDto>> mapListFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final map = <String, List<AlbumResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AlbumResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
final value = AlbumResponseDto.listFromJson(
|
||||
entry.value,
|
||||
growable: growable,
|
||||
);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -176,4 +189,3 @@ class AlbumResponseDto {
|
||||
'assets',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,25 +14,31 @@ class AssetFileUploadResponseDto {
|
||||
/// Returns a new [AssetFileUploadResponseDto] instance.
|
||||
AssetFileUploadResponseDto({
|
||||
required this.id,
|
||||
required this.duplicate,
|
||||
});
|
||||
|
||||
String id;
|
||||
|
||||
bool duplicate;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetFileUploadResponseDto &&
|
||||
other.id == id;
|
||||
other.id == id &&
|
||||
other.duplicate == duplicate;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(id.hashCode);
|
||||
(id.hashCode) +
|
||||
(duplicate.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetFileUploadResponseDto[id=$id]';
|
||||
String toString() => 'AssetFileUploadResponseDto[id=$id, duplicate=$duplicate]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'id'] = this.id;
|
||||
json[r'duplicate'] = this.duplicate;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -56,6 +62,7 @@ class AssetFileUploadResponseDto {
|
||||
|
||||
return AssetFileUploadResponseDto(
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
duplicate: mapValueOfType<bool>(json, r'duplicate')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -106,6 +113,7 @@ class AssetFileUploadResponseDto {
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'id',
|
||||
'duplicate',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
133
mobile/openapi/lib/model/asset_response_dto.dart
generated
133
mobile/openapi/lib/model/asset_response_dto.dart
generated
@@ -82,73 +82,76 @@ class AssetResponseDto {
|
||||
List<TagResponseDto> tags;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||
other.type == type &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.ownerId == ownerId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.mimeType == mimeType &&
|
||||
other.duration == duration &&
|
||||
other.webpPath == webpPath &&
|
||||
other.encodedVideoPath == encodedVideoPath &&
|
||||
other.exifInfo == exifInfo &&
|
||||
other.smartInfo == smartInfo &&
|
||||
other.livePhotoVideoId == livePhotoVideoId &&
|
||||
other.tags == tags;
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AssetResponseDto &&
|
||||
other.type == type &&
|
||||
other.id == id &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.ownerId == ownerId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.originalPath == originalPath &&
|
||||
other.resizePath == resizePath &&
|
||||
other.createdAt == createdAt &&
|
||||
other.modifiedAt == modifiedAt &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.mimeType == mimeType &&
|
||||
other.duration == duration &&
|
||||
other.webpPath == webpPath &&
|
||||
other.encodedVideoPath == encodedVideoPath &&
|
||||
other.exifInfo == exifInfo &&
|
||||
other.smartInfo == smartInfo &&
|
||||
other.livePhotoVideoId == livePhotoVideoId &&
|
||||
other.tags == tags;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(type.hashCode) +
|
||||
(id.hashCode) +
|
||||
(deviceAssetId.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(deviceId.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(resizePath == null ? 0 : resizePath!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(modifiedAt.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(mimeType == null ? 0 : mimeType!.hashCode) +
|
||||
(duration.hashCode) +
|
||||
(webpPath == null ? 0 : webpPath!.hashCode) +
|
||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||
(smartInfo == null ? 0 : smartInfo!.hashCode) +
|
||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
||||
(tags.hashCode);
|
||||
// ignore: unnecessary_parenthesis
|
||||
(type.hashCode) +
|
||||
(id.hashCode) +
|
||||
(deviceAssetId.hashCode) +
|
||||
(ownerId.hashCode) +
|
||||
(deviceId.hashCode) +
|
||||
(originalPath.hashCode) +
|
||||
(resizePath == null ? 0 : resizePath!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(modifiedAt.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(mimeType == null ? 0 : mimeType!.hashCode) +
|
||||
(duration.hashCode) +
|
||||
(webpPath == null ? 0 : webpPath!.hashCode) +
|
||||
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
|
||||
(exifInfo == null ? 0 : exifInfo!.hashCode) +
|
||||
(smartInfo == null ? 0 : smartInfo!.hashCode) +
|
||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
||||
(tags.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags]';
|
||||
String toString() =>
|
||||
'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'type'] = this.type;
|
||||
json[r'id'] = this.id;
|
||||
json[r'deviceAssetId'] = this.deviceAssetId;
|
||||
json[r'ownerId'] = this.ownerId;
|
||||
json[r'deviceId'] = this.deviceId;
|
||||
json[r'originalPath'] = this.originalPath;
|
||||
json[r'type'] = this.type;
|
||||
json[r'id'] = this.id;
|
||||
json[r'deviceAssetId'] = this.deviceAssetId;
|
||||
json[r'ownerId'] = this.ownerId;
|
||||
json[r'deviceId'] = this.deviceId;
|
||||
json[r'originalPath'] = this.originalPath;
|
||||
if (this.resizePath != null) {
|
||||
json[r'resizePath'] = this.resizePath;
|
||||
} else {
|
||||
// json[r'resizePath'] = null;
|
||||
}
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
json[r'modifiedAt'] = this.modifiedAt;
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
json[r'modifiedAt'] = this.modifiedAt;
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
if (this.mimeType != null) {
|
||||
json[r'mimeType'] = this.mimeType;
|
||||
} else {
|
||||
// json[r'mimeType'] = null;
|
||||
}
|
||||
json[r'duration'] = this.duration;
|
||||
json[r'duration'] = this.duration;
|
||||
if (this.webpPath != null) {
|
||||
json[r'webpPath'] = this.webpPath;
|
||||
} else {
|
||||
@@ -174,7 +177,7 @@ class AssetResponseDto {
|
||||
} else {
|
||||
// json[r'livePhotoVideoId'] = null;
|
||||
}
|
||||
json[r'tags'] = this.tags;
|
||||
json[r'tags'] = this.tags;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -188,13 +191,13 @@ class AssetResponseDto {
|
||||
// Ensure that the map contains the required keys.
|
||||
// Note 1: the values aren't checked for validity beyond being non-null.
|
||||
// Note 2: this code is stripped in release mode!
|
||||
assert(() {
|
||||
requiredKeys.forEach((key) {
|
||||
assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
|
||||
assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
|
||||
});
|
||||
return true;
|
||||
}());
|
||||
// assert(() {
|
||||
// requiredKeys.forEach((key) {
|
||||
// assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.');
|
||||
// assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.');
|
||||
// });
|
||||
// return true;
|
||||
// }());
|
||||
|
||||
return AssetResponseDto(
|
||||
type: AssetTypeEnum.fromJson(json[r'type'])!,
|
||||
@@ -220,7 +223,10 @@ class AssetResponseDto {
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
static List<AssetResponseDto>? listFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final result = <AssetResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
@@ -248,12 +254,18 @@ class AssetResponseDto {
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
static Map<String, List<AssetResponseDto>> mapListFromJson(
|
||||
dynamic json, {
|
||||
bool growable = false,
|
||||
}) {
|
||||
final map = <String, List<AssetResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
final value = AssetResponseDto.listFromJson(
|
||||
entry.value,
|
||||
growable: growable,
|
||||
);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
@@ -280,4 +292,3 @@ class AssetResponseDto {
|
||||
'tags',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool duplicate
|
||||
test('to test the property `duplicate`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ packages:
|
||||
name: cross_file
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.3.3+1"
|
||||
version: "0.3.3+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -239,6 +239,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
easy_image_viewer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: easy_image_viewer
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
easy_localization:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -757,13 +764,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
photo_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: photo_view
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.14.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -861,42 +861,14 @@ packages:
|
||||
name: share_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.10"
|
||||
share_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
share_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "6.3.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
share_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
share_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.2.0"
|
||||
shared_preferences:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1160,14 +1132,14 @@ packages:
|
||||
name: url_launcher_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.5"
|
||||
version: "2.1.1"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.11"
|
||||
version: "2.0.14"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1265,7 +1237,7 @@ packages:
|
||||
name: wakelock_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.2.1"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1293,7 +1265,7 @@ packages:
|
||||
name: win32
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
version: "3.1.3"
|
||||
wkt_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -23,7 +23,6 @@ dependencies:
|
||||
video_player: ^2.2.18
|
||||
chewie: ^1.3.5
|
||||
badges: ^2.0.2
|
||||
photo_view: ^0.14.0
|
||||
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
||||
flutter_map: ^0.14.0
|
||||
flutter_udid: ^2.0.0
|
||||
@@ -32,7 +31,7 @@ dependencies:
|
||||
http: 0.13.4
|
||||
cancellation_token_http: ^1.1.0
|
||||
easy_localization: ^3.0.1
|
||||
share_plus: ^4.0.10
|
||||
share_plus: ^6.3.0
|
||||
flutter_displaymode: ^0.4.0
|
||||
scrollable_positioned_list: ^0.3.4
|
||||
path: ^1.8.1
|
||||
@@ -41,6 +40,7 @@ dependencies:
|
||||
collection: ^1.16.0
|
||||
http_parser: ^4.0.1
|
||||
flutter_web_auth: ^0.5.0
|
||||
easy_image_viewer: ^1.2.0
|
||||
|
||||
openapi:
|
||||
path: openapi
|
||||
|
||||
@@ -101,7 +101,7 @@ export class AlbumService {
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
|
||||
for (const sharedLink of album.sharedLinks) {
|
||||
await this.shareCore.remove(sharedLink.id, authUser.id);
|
||||
await this.shareCore.remove(authUser.id, sharedLink.id);
|
||||
}
|
||||
|
||||
await this._albumRepository.delete(album);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { SearchPropertiesDto } from './dto/search-properties.dto';
|
||||
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
||||
import { AssetEntity, AssetType } from '@app/infra';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm/repository/Repository';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
@@ -19,15 +18,10 @@ import { IsNull, Not } from 'typeorm';
|
||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||
|
||||
export interface IAssetRepository {
|
||||
create(
|
||||
createAssetDto: CreateAssetDto,
|
||||
ownerId: string,
|
||||
originalPath: string,
|
||||
mimeType: string,
|
||||
isVisible: boolean,
|
||||
checksum?: Buffer,
|
||||
livePhotoAssetEntity?: AssetEntity,
|
||||
): Promise<AssetEntity>;
|
||||
get(id: string): Promise<AssetEntity | null>;
|
||||
create(asset: Omit<AssetEntity, 'id'>): Promise<AssetEntity>;
|
||||
remove(asset: AssetEntity): Promise<void>;
|
||||
|
||||
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
|
||||
getAll(): Promise<AssetEntity[]>;
|
||||
getAllVideos(): Promise<AssetEntity[]>;
|
||||
@@ -282,44 +276,16 @@ export class AssetRepository implements IAssetRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new asset information in database
|
||||
* @param createAssetDto
|
||||
* @param ownerId
|
||||
* @param originalPath
|
||||
* @param mimeType
|
||||
* @returns Promise<AssetEntity>
|
||||
*/
|
||||
async create(
|
||||
createAssetDto: CreateAssetDto,
|
||||
ownerId: string,
|
||||
originalPath: string,
|
||||
mimeType: string,
|
||||
isVisible: boolean,
|
||||
checksum?: Buffer,
|
||||
livePhotoAssetEntity?: AssetEntity,
|
||||
): Promise<AssetEntity> {
|
||||
const asset = new AssetEntity();
|
||||
asset.deviceAssetId = createAssetDto.deviceAssetId;
|
||||
asset.userId = ownerId;
|
||||
asset.deviceId = createAssetDto.deviceId;
|
||||
asset.type = !isVisible ? AssetType.VIDEO : createAssetDto.assetType || AssetType.OTHER; // If an asset is not visible, it is a LivePhotos video portion, therefore we can confidently assign the type as VIDEO here
|
||||
asset.originalPath = originalPath;
|
||||
asset.createdAt = createAssetDto.createdAt;
|
||||
asset.modifiedAt = createAssetDto.modifiedAt;
|
||||
asset.isFavorite = createAssetDto.isFavorite;
|
||||
asset.mimeType = mimeType;
|
||||
asset.duration = createAssetDto.duration || null;
|
||||
asset.checksum = checksum || null;
|
||||
asset.isVisible = isVisible;
|
||||
asset.livePhotoVideoId = livePhotoAssetEntity ? livePhotoAssetEntity.id : null;
|
||||
get(id: string): Promise<AssetEntity | null> {
|
||||
return this.assetRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
const createdAsset = await this.assetRepository.save(asset);
|
||||
async create(asset: Omit<AssetEntity, 'id'>): Promise<AssetEntity> {
|
||||
return this.assetRepository.save(asset);
|
||||
}
|
||||
|
||||
if (!createdAsset) {
|
||||
throw new BadRequestException('Asset not created');
|
||||
}
|
||||
return createdAsset;
|
||||
async remove(asset: AssetEntity): Promise<void> {
|
||||
await this.assetRepository.remove(asset);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,11 +19,9 @@ import {
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
import { AssetService } from './asset.service';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { ServeFileDto } from './dto/serve-file.dto';
|
||||
import { Response as Res } from 'express';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||
@@ -33,9 +31,9 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re
|
||||
import { AssetResponseDto } from '@app/domain';
|
||||
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
|
||||
import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
|
||||
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
||||
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
|
||||
import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto';
|
||||
import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
|
||||
import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
@@ -55,12 +53,13 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
||||
import { SharedLinkResponseDto } from '@app/domain';
|
||||
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
|
||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('Asset')
|
||||
@Controller('asset')
|
||||
export class AssetController {
|
||||
constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {}
|
||||
constructor(private assetService: AssetService) {}
|
||||
|
||||
@Authenticated({ isShared: true })
|
||||
@Post('upload')
|
||||
@@ -81,13 +80,22 @@ export class AssetController {
|
||||
async uploadFile(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] },
|
||||
@Body(ValidationPipe) createAssetDto: CreateAssetDto,
|
||||
@Body(ValidationPipe) dto: CreateAssetDto,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
): Promise<AssetFileUploadResponseDto> {
|
||||
const originalAssetData = files.assetData[0];
|
||||
const livePhotoAssetData = files.livePhotoData?.[0];
|
||||
const file = mapToUploadFile(files.assetData[0]);
|
||||
const _livePhotoFile = files.livePhotoData?.[0];
|
||||
let livePhotoFile;
|
||||
if (_livePhotoFile) {
|
||||
livePhotoFile = mapToUploadFile(_livePhotoFile);
|
||||
}
|
||||
|
||||
return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData);
|
||||
const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile);
|
||||
if (responseDto.duplicate) {
|
||||
res.send(200);
|
||||
}
|
||||
|
||||
return responseDto;
|
||||
}
|
||||
|
||||
@Authenticated({ isShared: true })
|
||||
@@ -276,37 +284,10 @@ export class AssetController {
|
||||
@Delete('/')
|
||||
async deleteAsset(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) assetIds: DeleteAssetDto,
|
||||
@Body(ValidationPipe) dto: DeleteAssetDto,
|
||||
): Promise<DeleteAssetResponseDto[]> {
|
||||
await this.assetService.checkAssetsAccess(authUser, assetIds.ids, true);
|
||||
|
||||
const deleteAssetList: AssetResponseDto[] = [];
|
||||
|
||||
for (const id of assetIds.ids) {
|
||||
const assets = await this.assetService.getAssetById(authUser, id);
|
||||
if (!assets) {
|
||||
continue;
|
||||
}
|
||||
deleteAssetList.push(assets);
|
||||
|
||||
if (assets.livePhotoVideoId) {
|
||||
const livePhotoVideo = await this.assetService.getAssetById(authUser, assets.livePhotoVideoId);
|
||||
if (livePhotoVideo) {
|
||||
deleteAssetList.push(livePhotoVideo);
|
||||
assetIds.ids = [...assetIds.ids, livePhotoVideo.id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.assetService.deleteAssetById(assetIds);
|
||||
|
||||
result.forEach((res) => {
|
||||
deleteAssetList.filter((a) => a.id == res.id && res.status == DeleteAssetStatusEnum.SUCCESS);
|
||||
});
|
||||
|
||||
await this.backgroundTaskService.deleteFileOnDisk(deleteAssetList as any[]);
|
||||
|
||||
return result;
|
||||
await this.assetService.checkAssetsAccess(authUser, dto.ids, true);
|
||||
return this.assetService.deleteAll(authUser, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
52
server/apps/immich/src/api-v1/asset/asset.core.ts
Normal file
52
server/apps/immich/src/api-v1/asset/asset.core.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { timeUtils } from '@app/common';
|
||||
import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
|
||||
import { AssetEntity } from '@app/infra/db/entities';
|
||||
import { StorageService } from '@app/storage';
|
||||
import { IAssetRepository } from './asset-repository';
|
||||
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
|
||||
|
||||
export class AssetCore {
|
||||
constructor(
|
||||
private repository: IAssetRepository,
|
||||
private jobRepository: IJobRepository,
|
||||
private storageService: StorageService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
authUser: AuthUserDto,
|
||||
dto: CreateAssetDto,
|
||||
file: UploadFile,
|
||||
livePhotoAssetId?: string,
|
||||
): Promise<AssetEntity> {
|
||||
let asset = await this.repository.create({
|
||||
userId: authUser.id,
|
||||
|
||||
mimeType: file.mimeType,
|
||||
checksum: file.checksum || null,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
createdAt: timeUtils.checkValidTimestamp(dto.createdAt) ? dto.createdAt : new Date().toISOString(),
|
||||
modifiedAt: timeUtils.checkValidTimestamp(dto.modifiedAt) ? dto.modifiedAt : new Date().toISOString(),
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
|
||||
type: dto.assetType,
|
||||
isFavorite: dto.isFavorite,
|
||||
duration: dto.duration || null,
|
||||
isVisible: dto.isVisible ?? true,
|
||||
livePhotoVideoId: livePhotoAssetId || null,
|
||||
resizePath: null,
|
||||
webpPath: null,
|
||||
encodedVideoPath: null,
|
||||
tags: [],
|
||||
sharedLinks: [],
|
||||
});
|
||||
|
||||
asset = await this.storageService.moveAsset(asset, file.originalName);
|
||||
|
||||
await this.jobRepository.add({ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } });
|
||||
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { AssetService } from './asset.service';
|
||||
import { AssetController } from './asset.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AssetEntity } from '@app/infra';
|
||||
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { CommunicationModule } from '../communication/communication.module';
|
||||
import { AssetRepository, IAssetRepository } from './asset-repository';
|
||||
import { DownloadModule } from '../../modules/download/download.module';
|
||||
@@ -21,14 +19,13 @@ const ASSET_REPOSITORY_PROVIDER = {
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AssetEntity]),
|
||||
CommunicationModule,
|
||||
BackgroundTaskModule,
|
||||
DownloadModule,
|
||||
TagModule,
|
||||
StorageModule,
|
||||
forwardRef(() => AlbumModule),
|
||||
],
|
||||
controllers: [AssetController],
|
||||
providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER],
|
||||
providers: [AssetService, ASSET_REPOSITORY_PROVIDER],
|
||||
exports: [ASSET_REPOSITORY_PROVIDER],
|
||||
})
|
||||
export class AssetModule {}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { IAssetRepository } from './asset-repository';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { AssetService } from './asset.service';
|
||||
import { Repository } from 'typeorm';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
import { AssetEntity, AssetType } from '@app/infra';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
import { DownloadService } from '../../modules/download/download.service';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
|
||||
import { StorageService } from '@app/storage';
|
||||
import { ICryptoRepository, IJobRepository, ISharedLinkRepository } from '@app/domain';
|
||||
import { ICryptoRepository, IJobRepository, ISharedLinkRepository, JobName } from '@app/domain';
|
||||
import {
|
||||
authStub,
|
||||
newCryptoRepositoryMock,
|
||||
@@ -23,105 +21,102 @@ import {
|
||||
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
const _getCreateAssetDto = (): CreateAssetDto => {
|
||||
const createAssetDto = new CreateAssetDto();
|
||||
createAssetDto.deviceAssetId = 'deviceAssetId';
|
||||
createAssetDto.deviceId = 'deviceId';
|
||||
createAssetDto.assetType = AssetType.OTHER;
|
||||
createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.isFavorite = false;
|
||||
createAssetDto.duration = '0:00:00.000000';
|
||||
|
||||
return createAssetDto;
|
||||
};
|
||||
|
||||
const _getAsset_1 = () => {
|
||||
const asset_1 = new AssetEntity();
|
||||
|
||||
asset_1.id = 'id_1';
|
||||
asset_1.userId = 'user_id_1';
|
||||
asset_1.deviceAssetId = 'device_asset_id_1';
|
||||
asset_1.deviceId = 'device_id_1';
|
||||
asset_1.type = AssetType.VIDEO;
|
||||
asset_1.originalPath = 'fake_path/asset_1.jpeg';
|
||||
asset_1.resizePath = '';
|
||||
asset_1.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_1.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_1.isFavorite = false;
|
||||
asset_1.mimeType = 'image/jpeg';
|
||||
asset_1.webpPath = '';
|
||||
asset_1.encodedVideoPath = '';
|
||||
asset_1.duration = '0:00:00.000000';
|
||||
return asset_1;
|
||||
};
|
||||
|
||||
const _getAsset_2 = () => {
|
||||
const asset_2 = new AssetEntity();
|
||||
|
||||
asset_2.id = 'id_2';
|
||||
asset_2.userId = 'user_id_1';
|
||||
asset_2.deviceAssetId = 'device_asset_id_2';
|
||||
asset_2.deviceId = 'device_id_1';
|
||||
asset_2.type = AssetType.VIDEO;
|
||||
asset_2.originalPath = 'fake_path/asset_2.jpeg';
|
||||
asset_2.resizePath = '';
|
||||
asset_2.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_2.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_2.isFavorite = false;
|
||||
asset_2.mimeType = 'image/jpeg';
|
||||
asset_2.webpPath = '';
|
||||
asset_2.encodedVideoPath = '';
|
||||
asset_2.duration = '0:00:00.000000';
|
||||
|
||||
return asset_2;
|
||||
};
|
||||
|
||||
const _getAssets = () => {
|
||||
return [_getAsset_1(), _getAsset_2()];
|
||||
};
|
||||
|
||||
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
|
||||
const result1 = new AssetCountByTimeBucket();
|
||||
result1.count = 2;
|
||||
result1.timeBucket = '2022-06-01T00:00:00.000Z';
|
||||
|
||||
const result2 = new AssetCountByTimeBucket();
|
||||
result1.count = 5;
|
||||
result1.timeBucket = '2022-07-01T00:00:00.000Z';
|
||||
|
||||
return [result1, result2];
|
||||
};
|
||||
|
||||
const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
|
||||
const result = new AssetCountByUserIdResponseDto();
|
||||
|
||||
result.videos = 2;
|
||||
result.photos = 2;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('AssetService', () => {
|
||||
let sui: AssetService;
|
||||
let sut: AssetService;
|
||||
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
|
||||
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
||||
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
|
||||
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
|
||||
let storageSeriveMock: jest.Mocked<StorageService>;
|
||||
let storageServiceMock: jest.Mocked<StorageService>;
|
||||
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
const authUser: AuthUserDto = Object.freeze({
|
||||
id: 'user_id_1',
|
||||
email: 'auth@test.com',
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
const _getCreateAssetDto = (): CreateAssetDto => {
|
||||
const createAssetDto = new CreateAssetDto();
|
||||
createAssetDto.deviceAssetId = 'deviceAssetId';
|
||||
createAssetDto.deviceId = 'deviceId';
|
||||
createAssetDto.assetType = AssetType.OTHER;
|
||||
createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.isFavorite = false;
|
||||
createAssetDto.duration = '0:00:00.000000';
|
||||
|
||||
return createAssetDto;
|
||||
};
|
||||
|
||||
const _getAsset_1 = () => {
|
||||
const asset_1 = new AssetEntity();
|
||||
|
||||
asset_1.id = 'id_1';
|
||||
asset_1.userId = 'user_id_1';
|
||||
asset_1.deviceAssetId = 'device_asset_id_1';
|
||||
asset_1.deviceId = 'device_id_1';
|
||||
asset_1.type = AssetType.VIDEO;
|
||||
asset_1.originalPath = 'fake_path/asset_1.jpeg';
|
||||
asset_1.resizePath = '';
|
||||
asset_1.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_1.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_1.isFavorite = false;
|
||||
asset_1.mimeType = 'image/jpeg';
|
||||
asset_1.webpPath = '';
|
||||
asset_1.encodedVideoPath = '';
|
||||
asset_1.duration = '0:00:00.000000';
|
||||
return asset_1;
|
||||
};
|
||||
|
||||
const _getAsset_2 = () => {
|
||||
const asset_2 = new AssetEntity();
|
||||
|
||||
asset_2.id = 'id_2';
|
||||
asset_2.userId = 'user_id_1';
|
||||
asset_2.deviceAssetId = 'device_asset_id_2';
|
||||
asset_2.deviceId = 'device_id_1';
|
||||
asset_2.type = AssetType.VIDEO;
|
||||
asset_2.originalPath = 'fake_path/asset_2.jpeg';
|
||||
asset_2.resizePath = '';
|
||||
asset_2.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_2.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_2.isFavorite = false;
|
||||
asset_2.mimeType = 'image/jpeg';
|
||||
asset_2.webpPath = '';
|
||||
asset_2.encodedVideoPath = '';
|
||||
asset_2.duration = '0:00:00.000000';
|
||||
|
||||
return asset_2;
|
||||
};
|
||||
|
||||
const _getAssets = () => {
|
||||
return [_getAsset_1(), _getAsset_2()];
|
||||
};
|
||||
|
||||
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
|
||||
const result1 = new AssetCountByTimeBucket();
|
||||
result1.count = 2;
|
||||
result1.timeBucket = '2022-06-01T00:00:00.000Z';
|
||||
|
||||
const result2 = new AssetCountByTimeBucket();
|
||||
result1.count = 5;
|
||||
result1.timeBucket = '2022-07-01T00:00:00.000Z';
|
||||
|
||||
return [result1, result2];
|
||||
};
|
||||
|
||||
const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
|
||||
const result = new AssetCountByUserIdResponseDto();
|
||||
|
||||
result.videos = 2;
|
||||
result.photos = 2;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
beforeEach(() => {
|
||||
assetRepositoryMock = {
|
||||
get: jest.fn(),
|
||||
create: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
|
||||
update: jest.fn(),
|
||||
getAll: jest.fn(),
|
||||
getAllVideos: jest.fn(),
|
||||
@@ -151,18 +146,21 @@ describe('AssetService', () => {
|
||||
downloadArchive: jest.fn(),
|
||||
};
|
||||
|
||||
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
|
||||
storageServiceMock = {
|
||||
moveAsset: jest.fn(),
|
||||
removeEmptyDirectories: jest.fn(),
|
||||
} as unknown as jest.Mocked<StorageService>;
|
||||
|
||||
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
|
||||
sui = new AssetService(
|
||||
sut = new AssetService(
|
||||
assetRepositoryMock,
|
||||
albumRepositoryMock,
|
||||
a,
|
||||
backgroundTaskServiceMock,
|
||||
downloadServiceMock as DownloadService,
|
||||
storageSeriveMock,
|
||||
storageServiceMock,
|
||||
sharedLinkRepositoryMock,
|
||||
jobMock,
|
||||
cryptoMock,
|
||||
@@ -178,7 +176,7 @@ describe('AssetService', () => {
|
||||
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
|
||||
sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid);
|
||||
|
||||
await expect(sui.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
await expect(sut.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
|
||||
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
|
||||
expect(assetRepositoryMock.countByIdAndUser).toHaveBeenCalledWith(asset1.id, authStub.user1.id);
|
||||
@@ -196,7 +194,7 @@ describe('AssetService', () => {
|
||||
sharedLinkRepositoryMock.get.mockResolvedValue(null);
|
||||
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
|
||||
|
||||
await expect(sui.updateAssetsInSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.updateAssetsInSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
|
||||
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
||||
@@ -215,7 +213,7 @@ describe('AssetService', () => {
|
||||
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
|
||||
sharedLinkRepositoryMock.save.mockResolvedValue(sharedLinkStub.valid);
|
||||
|
||||
await expect(sui.updateAssetsInSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
await expect(sut.updateAssetsInSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
|
||||
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
|
||||
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
||||
@@ -223,27 +221,94 @@ describe('AssetService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Currently failing due to calculate checksum from a file
|
||||
it('create an asset', async () => {
|
||||
const assetEntity = _getAsset_1();
|
||||
describe('uploadFile', () => {
|
||||
it('should handle a file upload', async () => {
|
||||
const assetEntity = _getAsset_1();
|
||||
const file = {
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
};
|
||||
const dto = _getCreateAssetDto();
|
||||
|
||||
assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity));
|
||||
assetRepositoryMock.create.mockImplementation(() => Promise.resolve(assetEntity));
|
||||
storageServiceMock.moveAsset.mockResolvedValue({ ...assetEntity, originalPath: 'fake_new_path/asset_123.jpeg' });
|
||||
|
||||
const originalPath = 'fake_path/asset_1.jpeg';
|
||||
const mimeType = 'image/jpeg';
|
||||
const createAssetDto = _getCreateAssetDto();
|
||||
const result = await sui.createUserAsset(
|
||||
authUser,
|
||||
createAssetDto,
|
||||
originalPath,
|
||||
mimeType,
|
||||
Buffer.from('0x5041E6328F7DF8AFF650BEDAED9251897D9A6241', 'hex'),
|
||||
true,
|
||||
);
|
||||
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
|
||||
});
|
||||
|
||||
expect(result.userId).toEqual(authUser.id);
|
||||
expect(result.resizePath).toEqual('');
|
||||
expect(result.webpPath).toEqual('');
|
||||
it('should handle a duplicate', async () => {
|
||||
const file = {
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
};
|
||||
const dto = _getCreateAssetDto();
|
||||
const error = new QueryFailedError('', [], '');
|
||||
(error as any).constraint = 'UQ_userid_checksum';
|
||||
|
||||
assetRepositoryMock.create.mockRejectedValue(error);
|
||||
assetRepositoryMock.getAssetByChecksum.mockResolvedValue(_getAsset_1());
|
||||
|
||||
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });
|
||||
|
||||
expect(jobMock.add).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILE_ON_DISK,
|
||||
data: { assets: [{ originalPath: 'fake_path/asset_1.jpeg', resizePath: null }] },
|
||||
});
|
||||
expect(storageServiceMock.moveAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a live photo', async () => {
|
||||
const file = {
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
};
|
||||
const asset = {
|
||||
id: 'live-photo-asset',
|
||||
originalPath: file.originalPath,
|
||||
userId: authStub.user1.id,
|
||||
type: AssetType.IMAGE,
|
||||
isVisible: true,
|
||||
} as AssetEntity;
|
||||
|
||||
const livePhotoFile = {
|
||||
originalPath: 'fake_path/asset_1.mp4',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('live photo file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
};
|
||||
|
||||
const livePhotoAsset = {
|
||||
id: 'live-photo-motion',
|
||||
originalPath: livePhotoFile.originalPath,
|
||||
userId: authStub.user1.id,
|
||||
type: AssetType.VIDEO,
|
||||
isVisible: false,
|
||||
} as AssetEntity;
|
||||
|
||||
const dto = _getCreateAssetDto();
|
||||
const error = new QueryFailedError('', [], '');
|
||||
(error as any).constraint = 'UQ_userid_checksum';
|
||||
|
||||
assetRepositoryMock.create.mockResolvedValueOnce(livePhotoAsset);
|
||||
assetRepositoryMock.create.mockResolvedValueOnce(asset);
|
||||
storageServiceMock.moveAsset.mockImplementation((asset) => Promise.resolve(asset));
|
||||
|
||||
await expect(sut.uploadFile(authStub.user1, dto, file, livePhotoFile)).resolves.toEqual({
|
||||
duplicate: false,
|
||||
id: 'live-photo-asset',
|
||||
});
|
||||
|
||||
expect(jobMock.add.mock.calls).toEqual([
|
||||
[{ name: JobName.ASSET_UPLOADED, data: { asset: livePhotoAsset, fileName: file.originalName } }],
|
||||
[{ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('get assets by device id', async () => {
|
||||
@@ -254,7 +319,7 @@ describe('AssetService', () => {
|
||||
);
|
||||
|
||||
const deviceId = 'device_id_1';
|
||||
const result = await sui.getUserAssetsByDeviceId(authUser, deviceId);
|
||||
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
|
||||
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
|
||||
@@ -267,7 +332,7 @@ describe('AssetService', () => {
|
||||
Promise.resolve<AssetCountByTimeBucket[]>(assetCountByTimeBucket),
|
||||
);
|
||||
|
||||
const result = await sui.getAssetCountByTimeBucket(authUser, {
|
||||
const result = await sut.getAssetCountByTimeBucket(authStub.user1, {
|
||||
timeGroup: TimeGroupEnum.Month,
|
||||
});
|
||||
|
||||
@@ -282,18 +347,70 @@ describe('AssetService', () => {
|
||||
Promise.resolve<AssetCountByUserIdResponseDto>(assetCount),
|
||||
);
|
||||
|
||||
const result = await sui.getAssetCountByUserId(authUser);
|
||||
const result = await sut.getAssetCountByUserId(authStub.user1);
|
||||
|
||||
expect(result).toEqual(assetCount);
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('should return failed status when an asset is missing', async () => {
|
||||
assetRepositoryMock.get.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
|
||||
{ id: 'asset1', status: 'FAILED' },
|
||||
]);
|
||||
|
||||
expect(jobMock.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return failed status a delete fails', async () => {
|
||||
assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity);
|
||||
assetRepositoryMock.remove.mockRejectedValue('delete failed');
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
|
||||
{ id: 'asset1', status: 'FAILED' },
|
||||
]);
|
||||
|
||||
expect(jobMock.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete a live photo', async () => {
|
||||
assetRepositoryMock.get.mockResolvedValueOnce({ id: 'asset1', livePhotoVideoId: 'live-photo' } as AssetEntity);
|
||||
assetRepositoryMock.get.mockResolvedValueOnce({ id: 'live-photo' } as AssetEntity);
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
|
||||
{ id: 'asset1', status: 'SUCCESS' },
|
||||
{ id: 'live-photo', status: 'SUCCESS' },
|
||||
]);
|
||||
|
||||
expect(jobMock.add).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILE_ON_DISK,
|
||||
data: { assets: [{ id: 'asset1', livePhotoVideoId: 'live-photo' }, { id: 'live-photo' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete a batch of assets', async () => {
|
||||
assetRepositoryMock.get.mockImplementation((id) => Promise.resolve({ id } as AssetEntity));
|
||||
assetRepositoryMock.remove.mockImplementation(() => Promise.resolve());
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([
|
||||
{ id: 'asset1', status: 'SUCCESS' },
|
||||
{ id: 'asset2', status: 'SUCCESS' },
|
||||
]);
|
||||
|
||||
expect(jobMock.add.mock.calls).toEqual([
|
||||
[{ name: JobName.DELETE_FILE_ON_DISK, data: { assets: [{ id: 'asset1' }, { id: 'asset2' }] } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkDownloadAccess', () => {
|
||||
it('should validate download access', async () => {
|
||||
await sui.checkDownloadAccess(authStub.adminSharedLink);
|
||||
await sut.checkDownloadAccess(authStub.adminSharedLink);
|
||||
});
|
||||
|
||||
it('should not allow when user is not allowed to download', async () => {
|
||||
expect(() => sui.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException);
|
||||
expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@ import { SearchAssetDto } from './dto/search-asset.dto';
|
||||
import fs from 'fs/promises';
|
||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '@app/domain';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { AssetResponseDto, JobName, mapAsset, mapAssetWithoutExif } from '@app/domain';
|
||||
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
|
||||
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
|
||||
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
|
||||
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
|
||||
@@ -37,13 +37,12 @@ import {
|
||||
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
import { timeUtils } from '@app/common/utils';
|
||||
import { AssetCore } from './asset.core';
|
||||
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
|
||||
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
|
||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { ICryptoRepository, IJobRepository, JobName } from '@app/domain';
|
||||
import { ICryptoRepository, IJobRepository } from '@app/domain';
|
||||
import { DownloadService } from '../../modules/download/download.service';
|
||||
import { DownloadDto } from './dto/download-library.dto';
|
||||
import { IAlbumRepository } from '../album/album-repository';
|
||||
@@ -55,7 +54,6 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
||||
import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
|
||||
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
|
||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||
import { ImmichFile } from '../../config/asset-upload.config';
|
||||
|
||||
const fileInfo = promisify(stat);
|
||||
|
||||
@@ -63,142 +61,69 @@ const fileInfo = promisify(stat);
|
||||
export class AssetService {
|
||||
readonly logger = new Logger(AssetService.name);
|
||||
private shareCore: ShareCore;
|
||||
private assetCore: AssetCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
|
||||
@Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
private backgroundTaskService: BackgroundTaskService,
|
||||
private downloadService: DownloadService,
|
||||
private storageService: StorageService,
|
||||
storageService: StorageService,
|
||||
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
) {
|
||||
this.assetCore = new AssetCore(_assetRepository, jobRepository, storageService);
|
||||
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
|
||||
}
|
||||
|
||||
public async handleUploadedAsset(
|
||||
public async uploadFile(
|
||||
authUser: AuthUserDto,
|
||||
createAssetDto: CreateAssetDto,
|
||||
res: Res,
|
||||
originalAssetData: ImmichFile,
|
||||
livePhotoAssetData?: ImmichFile,
|
||||
) {
|
||||
const checksum = originalAssetData.checksum;
|
||||
const isLivePhoto = livePhotoAssetData !== undefined;
|
||||
let livePhotoAssetEntity: AssetEntity | undefined;
|
||||
dto: CreateAssetDto,
|
||||
file: UploadFile,
|
||||
livePhotoFile?: UploadFile,
|
||||
): Promise<AssetFileUploadResponseDto> {
|
||||
if (livePhotoFile) {
|
||||
livePhotoFile.originalName = file.originalName;
|
||||
}
|
||||
|
||||
let livePhotoAsset: AssetEntity | null = null;
|
||||
|
||||
try {
|
||||
if (isLivePhoto) {
|
||||
const livePhotoChecksum = livePhotoAssetData.checksum;
|
||||
livePhotoAssetEntity = await this.createUserAsset(
|
||||
authUser,
|
||||
createAssetDto,
|
||||
livePhotoAssetData.path,
|
||||
livePhotoAssetData.mimetype,
|
||||
livePhotoChecksum,
|
||||
false,
|
||||
);
|
||||
|
||||
if (!livePhotoAssetEntity) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: livePhotoAssetData.path,
|
||||
} as any,
|
||||
]);
|
||||
throw new BadRequestException('Asset not created');
|
||||
}
|
||||
|
||||
await this.storageService.moveAsset(livePhotoAssetEntity, originalAssetData.originalname);
|
||||
|
||||
await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset: livePhotoAssetEntity } });
|
||||
if (livePhotoFile) {
|
||||
const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false };
|
||||
livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
|
||||
}
|
||||
|
||||
const assetEntity = await this.createUserAsset(
|
||||
authUser,
|
||||
createAssetDto,
|
||||
originalAssetData.path,
|
||||
originalAssetData.mimetype,
|
||||
checksum,
|
||||
true,
|
||||
livePhotoAssetEntity,
|
||||
);
|
||||
|
||||
if (!assetEntity) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: originalAssetData.path,
|
||||
} as any,
|
||||
]);
|
||||
throw new BadRequestException('Asset not created');
|
||||
}
|
||||
|
||||
const movedAsset = await this.storageService.moveAsset(assetEntity, originalAssetData.originalname);
|
||||
const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id);
|
||||
|
||||
return { id: asset.id, duplicate: false };
|
||||
} catch (error: any) {
|
||||
// clean up files
|
||||
await this.jobRepository.add({
|
||||
name: JobName.ASSET_UPLOADED,
|
||||
data: { asset: movedAsset, fileName: originalAssetData.originalname },
|
||||
name: JobName.DELETE_FILE_ON_DISK,
|
||||
data: {
|
||||
assets: [
|
||||
{
|
||||
originalPath: file.originalPath,
|
||||
resizePath: livePhotoFile?.originalPath || null,
|
||||
} as AssetEntity,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return new AssetFileUploadResponseDto(movedAsset.id);
|
||||
} catch (err) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: originalAssetData.path,
|
||||
} as any,
|
||||
]); // simulate asset to make use of delete queue (or use fs.unlink instead)
|
||||
|
||||
if (isLivePhoto) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: livePhotoAssetData.path,
|
||||
} as any,
|
||||
]);
|
||||
// handle duplicates with a success response
|
||||
if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
|
||||
const duplicate = await this.getAssetByChecksum(authUser.id, file.checksum);
|
||||
return { id: duplicate.id, duplicate: true };
|
||||
}
|
||||
|
||||
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
|
||||
const existedAsset = await this.getAssetByChecksum(authUser.id, checksum);
|
||||
res.status(200); // normal POST is 201. we use 200 to indicate the asset already exists
|
||||
return new AssetFileUploadResponseDto(existedAsset.id);
|
||||
}
|
||||
|
||||
Logger.error(`Error uploading file ${err}`);
|
||||
throw new BadRequestException(`Error uploading file`, `${err}`);
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
throw new BadRequestException(`Error uploading file`, `${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async createUserAsset(
|
||||
authUser: AuthUserDto,
|
||||
createAssetDto: CreateAssetDto,
|
||||
originalPath: string,
|
||||
mimeType: string,
|
||||
checksum: Buffer,
|
||||
isVisible: boolean,
|
||||
livePhotoAssetEntity?: AssetEntity,
|
||||
): Promise<AssetEntity> {
|
||||
if (!timeUtils.checkValidTimestamp(createAssetDto.createdAt)) {
|
||||
createAssetDto.createdAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
if (!timeUtils.checkValidTimestamp(createAssetDto.modifiedAt)) {
|
||||
createAssetDto.modifiedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
const assetEntity = await this._assetRepository.create(
|
||||
createAssetDto,
|
||||
authUser.id,
|
||||
originalPath,
|
||||
mimeType,
|
||||
isVisible,
|
||||
checksum,
|
||||
livePhotoAssetEntity,
|
||||
);
|
||||
|
||||
return assetEntity;
|
||||
}
|
||||
|
||||
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
|
||||
return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
|
||||
}
|
||||
@@ -520,26 +445,35 @@ export class AssetService {
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteAssetById(assetIds: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
|
||||
public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
|
||||
const deleteQueue: AssetEntity[] = [];
|
||||
const result: DeleteAssetResponseDto[] = [];
|
||||
|
||||
const target = assetIds.ids;
|
||||
for (const assetId of target) {
|
||||
const res = await this.assetRepository.delete({
|
||||
id: assetId,
|
||||
});
|
||||
|
||||
if (res.affected) {
|
||||
result.push({
|
||||
id: assetId,
|
||||
status: DeleteAssetStatusEnum.SUCCESS,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
id: assetId,
|
||||
status: DeleteAssetStatusEnum.FAILED,
|
||||
});
|
||||
const ids = dto.ids.slice();
|
||||
for (const id of ids) {
|
||||
const asset = await this._assetRepository.get(id);
|
||||
if (!asset) {
|
||||
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this._assetRepository.remove(asset);
|
||||
|
||||
result.push({ id: asset.id, status: DeleteAssetStatusEnum.SUCCESS });
|
||||
deleteQueue.push(asset as any);
|
||||
|
||||
// TODO refactor this to use cascades
|
||||
if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
|
||||
ids.push(asset.livePhotoVideoId);
|
||||
}
|
||||
} catch {
|
||||
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteQueue.length > 0) {
|
||||
await this.jobRepository.add({ name: JobName.DELETE_FILE_ON_DISK, data: { assets: deleteQueue } });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { AssetType } from '@app/infra';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { ImmichFile } from '../../../config/asset-upload.config';
|
||||
|
||||
export class CreateAssetDto {
|
||||
@IsNotEmpty()
|
||||
@@ -22,9 +23,29 @@ export class CreateAssetDto {
|
||||
@IsNotEmpty()
|
||||
isFavorite!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isVisible?: boolean;
|
||||
|
||||
@IsNotEmpty()
|
||||
fileExtension!: string;
|
||||
|
||||
@IsOptional()
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface UploadFile {
|
||||
mimeType: string;
|
||||
checksum: Buffer;
|
||||
originalPath: string;
|
||||
originalName: string;
|
||||
}
|
||||
|
||||
export function mapToUploadFile(file: ImmichFile): UploadFile {
|
||||
return {
|
||||
checksum: file.checksum,
|
||||
mimeType: file.mimetype,
|
||||
originalPath: file.path,
|
||||
originalName: file.originalname,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
export class AssetFileUploadResponseDto {
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
id: string;
|
||||
id!: string;
|
||||
duplicate!: boolean;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
this.logger.log(`New websocket connection: ${client.id}`);
|
||||
const user = await this.authService.validate(client.request.headers);
|
||||
const user = await this.authService.validate(client.request.headers, {});
|
||||
if (user) {
|
||||
client.join(user.id);
|
||||
} else {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Body, Controller, Put, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
import { DeviceInfoService } from './device-info.service';
|
||||
import { UpsertDeviceInfoDto } from './dto/upsert-device-info.dto';
|
||||
import { DeviceInfoResponseDto, mapDeviceInfoResponse } from './response-dto/device-info-response.dto';
|
||||
|
||||
@Authenticated()
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('Device Info')
|
||||
@Controller('device-info')
|
||||
export class DeviceInfoController {
|
||||
constructor(private readonly deviceInfoService: DeviceInfoService) {}
|
||||
|
||||
@Put()
|
||||
public async upsertDeviceInfo(
|
||||
@GetAuthUser() user: AuthUserDto,
|
||||
@Body(ValidationPipe) dto: UpsertDeviceInfoDto,
|
||||
): Promise<DeviceInfoResponseDto> {
|
||||
const deviceInfo = await this.deviceInfoService.upsert({ ...dto, userId: user.id });
|
||||
return mapDeviceInfoResponse(deviceInfo);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DeviceInfoService } from './device-info.service';
|
||||
import { DeviceInfoController } from './device-info.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DeviceInfoEntity } from '@app/infra';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([DeviceInfoEntity])],
|
||||
controllers: [DeviceInfoController],
|
||||
providers: [DeviceInfoService],
|
||||
})
|
||||
export class DeviceInfoModule {}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { DeviceInfoEntity } from '@app/infra';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
type EntityKeys = Pick<DeviceInfoEntity, 'deviceId' | 'userId'>;
|
||||
type Entity = EntityKeys & Partial<DeviceInfoEntity>;
|
||||
|
||||
@Injectable()
|
||||
export class DeviceInfoService {
|
||||
constructor(
|
||||
@InjectRepository(DeviceInfoEntity)
|
||||
private repository: Repository<DeviceInfoEntity>,
|
||||
) {}
|
||||
|
||||
public async upsert(entity: Entity): Promise<DeviceInfoEntity> {
|
||||
const { deviceId, userId } = entity;
|
||||
const exists = await this.repository.findOne({ where: { userId, deviceId } });
|
||||
|
||||
if (!exists) {
|
||||
if (!entity.isAutoBackup) {
|
||||
entity.isAutoBackup = false;
|
||||
}
|
||||
return await this.repository.save(entity);
|
||||
}
|
||||
|
||||
exists.isAutoBackup = entity.isAutoBackup ?? exists.isAutoBackup;
|
||||
exists.deviceType = entity.deviceType ?? exists.deviceType;
|
||||
return await this.repository.save(exists);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { immichAppConfig } from '@app/common/config';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { AssetModule } from './api-v1/asset/asset.module';
|
||||
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
|
||||
import { CommunicationModule } from './api-v1/communication/communication.module';
|
||||
import { AlbumModule } from './api-v1/album/album.module';
|
||||
import { AppController } from './app.controller';
|
||||
@@ -17,14 +15,14 @@ import { InfraModule } from '@app/infra';
|
||||
import {
|
||||
APIKeyController,
|
||||
AuthController,
|
||||
DeviceInfoController,
|
||||
OAuthController,
|
||||
ShareController,
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
} from './controllers';
|
||||
import { PublicShareStrategy } from './modules/immich-auth/strategies/public-share.strategy';
|
||||
import { APIKeyStrategy } from './modules/immich-auth/strategies/api-key.strategy';
|
||||
import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.strategy';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthGuard } from './middlewares/auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -36,12 +34,8 @@ import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.str
|
||||
|
||||
AssetModule,
|
||||
|
||||
DeviceInfoModule,
|
||||
|
||||
ServerInfoModule,
|
||||
|
||||
BackgroundTaskModule,
|
||||
|
||||
CommunicationModule,
|
||||
|
||||
AlbumModule,
|
||||
@@ -59,12 +53,13 @@ import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.str
|
||||
AppController,
|
||||
APIKeyController,
|
||||
AuthController,
|
||||
DeviceInfoController,
|
||||
OAuthController,
|
||||
ShareController,
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
],
|
||||
providers: [UserAuthStrategy, APIKeyStrategy, PublicShareStrategy],
|
||||
providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
// TODO: check if consumer is needed or remove
|
||||
|
||||
23
server/apps/immich/src/controllers/device-info.controller.ts
Normal file
23
server/apps/immich/src/controllers/device-info.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
AuthUserDto,
|
||||
DeviceInfoResponseDto as ResponseDto,
|
||||
DeviceInfoService,
|
||||
UpsertDeviceInfoDto as UpsertDto,
|
||||
} from '@app/domain';
|
||||
import { Body, Controller, Put, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||
|
||||
@Authenticated()
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('Device Info')
|
||||
@Controller('device-info')
|
||||
export class DeviceInfoController {
|
||||
constructor(private readonly service: DeviceInfoService) {}
|
||||
|
||||
@Put()
|
||||
upsertDeviceInfo(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) dto: UpsertDto): Promise<ResponseDto> {
|
||||
return this.service.upsert(authUser, dto);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './api-key.controller';
|
||||
export * from './auth.controller';
|
||||
export * from './device-info.controller';
|
||||
export * from './oauth.controller';
|
||||
export * from './share.controller';
|
||||
export * from './system-config.controller';
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware';
|
||||
import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware';
|
||||
import { AuthGuard } from '../modules/immich-auth/guards/auth.guard';
|
||||
import { applyDecorators, SetMetadata } from '@nestjs/common';
|
||||
|
||||
interface AuthenticatedOptions {
|
||||
admin?: boolean;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export enum Metadata {
|
||||
AUTH_ROUTE = 'auth_route',
|
||||
ADMIN_ROUTE = 'admin_route',
|
||||
SHARED_ROUTE = 'shared_route',
|
||||
}
|
||||
|
||||
export const Authenticated = (options?: AuthenticatedOptions) => {
|
||||
const guards: Parameters<typeof UseGuards> = [AuthGuard];
|
||||
const decorators = [SetMetadata(Metadata.AUTH_ROUTE, true)];
|
||||
|
||||
options = options || {};
|
||||
|
||||
if (options.admin) {
|
||||
guards.push(AdminRolesGuard);
|
||||
decorators.push(SetMetadata(Metadata.ADMIN_ROUTE, true));
|
||||
}
|
||||
|
||||
if (!options.isShared) {
|
||||
guards.push(RouteNotSharedGuard);
|
||||
if (options.isShared) {
|
||||
decorators.push(SetMetadata(Metadata.SHARED_ROUTE, true));
|
||||
}
|
||||
|
||||
return UseGuards(...guards);
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { UserResponseDto } from '@app/domain';
|
||||
|
||||
interface UserRequest extends Request {
|
||||
user: UserResponseDto;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminRolesGuard implements CanActivate {
|
||||
logger = new Logger(AdminRolesGuard.name);
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<UserRequest>();
|
||||
const isAdmin = request.user?.isAdmin || false;
|
||||
if (!isAdmin) {
|
||||
this.logger.log(`Denied access to admin only route: ${request.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
46
server/apps/immich/src/middlewares/auth.guard.ts
Normal file
46
server/apps/immich/src/middlewares/auth.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AuthService } from '@app/domain';
|
||||
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { Metadata } from '../decorators/authenticated.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private logger = new Logger(AuthGuard.name);
|
||||
|
||||
constructor(private reflector: Reflector, private authService: AuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const targets = [context.getHandler(), context.getClass()];
|
||||
|
||||
const isAuthRoute = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets);
|
||||
const isAdminRoute = this.reflector.getAllAndOverride(Metadata.ADMIN_ROUTE, targets);
|
||||
const isSharedRoute = this.reflector.getAllAndOverride(Metadata.SHARED_ROUTE, targets);
|
||||
|
||||
if (!isAuthRoute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
const authDto = await this.authService.validate(req.headers, req.query as Record<string, string>);
|
||||
if (!authDto) {
|
||||
this.logger.warn(`Denied access to authenticated route: ${req.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authDto.isPublicUser && !isSharedRoute) {
|
||||
this.logger.warn(`Denied access to non-shared route: ${req.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAdminRoute && !authDto.isAdmin) {
|
||||
this.logger.warn(`Denied access to admin only route: ${req.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
req.user = authDto;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,20 @@ const redisHost = process.env.REDIS_HOSTNAME || 'immich_redis';
|
||||
const redisPort = parseInt(process.env.REDIS_PORT || '6379');
|
||||
const redisDb = parseInt(process.env.REDIS_DBINDEX || '0');
|
||||
const redisPassword = process.env.REDIS_PASSWORD || undefined;
|
||||
// const redisSocket = process.env.REDIS_SOCKET || undefined;
|
||||
const redisSocket = process.env.REDIS_SOCKET || undefined;
|
||||
|
||||
export class RedisIoAdapter extends IoAdapter {
|
||||
private adapterConstructor: any;
|
||||
|
||||
async connectToRedis(): Promise<void> {
|
||||
const pubClient = createClient({
|
||||
url: `redis://${redisHost}:${redisPort}/${redisDb}`,
|
||||
password: redisPassword,
|
||||
database: redisDb,
|
||||
socket: {
|
||||
host: redisHost,
|
||||
port: redisPort,
|
||||
path: redisSocket,
|
||||
},
|
||||
});
|
||||
const subClient = pubClient.duplicate();
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { AuthUserDto } from '../decorators/auth-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RouteNotSharedGuard implements CanActivate {
|
||||
logger = new Logger(RouteNotSharedGuard.name);
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as AuthUserDto;
|
||||
|
||||
// Inverse logic - I know it is weird
|
||||
if (user.isPublicUser) {
|
||||
this.logger.warn(`Denied attempt to access non-shared route: ${request.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BackgroundTaskProcessor } from './background-task.processor';
|
||||
import { BackgroundTaskService } from './background-task.service';
|
||||
|
||||
@Module({
|
||||
providers: [BackgroundTaskService, BackgroundTaskProcessor],
|
||||
exports: [BackgroundTaskService],
|
||||
})
|
||||
export class BackgroundTaskModule {}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { IJobRepository, JobName } from '@app/domain';
|
||||
import { AssetEntity } from '@app/infra';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class BackgroundTaskService {
|
||||
constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
|
||||
|
||||
async deleteFileOnDisk(assets: AssetEntity[]) {
|
||||
await this.jobRepository.add({ name: JobName.DELETE_FILE_ON_DISK, data: { assets } });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
|
||||
import { API_KEY_STRATEGY } from '../strategies/api-key.strategy';
|
||||
import { AUTH_COOKIE_STRATEGY } from '../strategies/user-auth.strategy';
|
||||
import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, AUTH_COOKIE_STRATEGY, API_KEY_STRATEGY]) {}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { APIKeyService, AuthUserDto } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
|
||||
|
||||
export const API_KEY_STRATEGY = 'api-key';
|
||||
|
||||
const options: IStrategyOptions = {
|
||||
header: 'x-api-key',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY) {
|
||||
constructor(private apiKeyService: APIKeyService) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
validate(token: string): Promise<AuthUserDto> {
|
||||
return this.apiKeyService.validate(token);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
|
||||
import { AuthUserDto, ShareService } from '@app/domain';
|
||||
|
||||
export const PUBLIC_SHARE_STRATEGY = 'public-share';
|
||||
|
||||
const options: IStrategyOptions = {
|
||||
header: 'x-immich-share-key',
|
||||
param: 'key',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PublicShareStrategy extends PassportStrategy(Strategy, PUBLIC_SHARE_STRATEGY) {
|
||||
constructor(private shareService: ShareService) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
async validate(key: string): Promise<AuthUserDto> {
|
||||
return this.shareService.validate(key);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { AuthService, AuthUserDto, UserService } from '@app/domain';
|
||||
import { Strategy } from 'passport-custom';
|
||||
import { Request } from 'express';
|
||||
|
||||
export const AUTH_COOKIE_STRATEGY = 'auth-cookie';
|
||||
|
||||
@Injectable()
|
||||
export class UserAuthStrategy extends PassportStrategy(Strategy, AUTH_COOKIE_STRATEGY) {
|
||||
constructor(private userService: UserService, private authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(request: Request): Promise<AuthUserDto> {
|
||||
const authUser = await this.authService.validate(request.headers);
|
||||
|
||||
if (!authUser) {
|
||||
throw new UnauthorizedException('Incorrect token provided');
|
||||
}
|
||||
|
||||
return authUser;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { clearDb, getAuthUser, authCustom } from './test-utils';
|
||||
import { InfraModule } from '@app/infra';
|
||||
import { AlbumModule } from '../src/api-v1/album/album.module';
|
||||
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
|
||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||
import { AuthService, DomainModule, UserService } from '@app/domain';
|
||||
import { AuthService, UserService } from '@app/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
@@ -20,9 +18,7 @@ describe('Album', () => {
|
||||
|
||||
describe('without auth', () => {
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
|
||||
}).compile();
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
database = app.get(DataSource);
|
||||
@@ -46,9 +42,7 @@ describe('Album', () => {
|
||||
let authService: AuthService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const builder = Test.createTestingModule({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule],
|
||||
});
|
||||
const builder = Test.createTestingModule({ imports: [AppModule] });
|
||||
authUser = getAuthUser(); // set default auth user
|
||||
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { TestingModuleBuilder } from '@nestjs/testing';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||
import { AuthGuard } from '../src/modules/immich-auth/guards/auth.guard';
|
||||
import { AuthGuard } from '../src/middlewares/auth.guard';
|
||||
|
||||
type CustomAuthCallback = () => AuthUserDto;
|
||||
|
||||
@@ -34,5 +34,5 @@ export function authCustom(builder: TestingModuleBuilder, callback: CustomAuthCa
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return builder.overrideGuard(AuthGuard).useValue(canActivate);
|
||||
return builder.overrideProvider(AuthGuard).useValue(canActivate);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { clearDb, authCustom } from './test-utils';
|
||||
import { InfraModule } from '@app/infra';
|
||||
import { DomainModule, CreateUserDto, UserService, AuthUserDto } from '@app/domain';
|
||||
import { CreateUserDto, UserService, AuthUserDto } from '@app/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { UserController } from '../src/controllers';
|
||||
import { AuthService } from '@app/domain';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
@@ -24,10 +22,7 @@ describe('User', () => {
|
||||
|
||||
describe('without auth', () => {
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
|
||||
controllers: [UserController],
|
||||
}).compile();
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
database = app.get(DataSource);
|
||||
@@ -50,10 +45,7 @@ describe('User', () => {
|
||||
let authUser: AuthUserDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
const builder = Test.createTestingModule({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] })],
|
||||
controllers: [UserController],
|
||||
});
|
||||
const builder = Test.createTestingModule({ imports: [AppModule] });
|
||||
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
|
||||
@@ -12,7 +12,7 @@ async function bootstrap() {
|
||||
logger: getLogLevels(),
|
||||
});
|
||||
|
||||
const listeningPort = Number(process.env.MACHINE_LEARNING_PORT) || 3002;
|
||||
const listeningPort = Number(process.env.MICROSERVICES_PORT) || 3002;
|
||||
|
||||
const redisIoAdapter = new RedisIoAdapter(app);
|
||||
await redisIoAdapter.connectToRedis();
|
||||
|
||||
@@ -14,6 +14,7 @@ import { StorageMigrationProcessor } from './processors/storage-migration.proces
|
||||
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
|
||||
import { UserDeletionProcessor } from './processors/user-deletion.processor';
|
||||
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
|
||||
import { BackgroundTaskProcessor } from './processors/background-task.processor';
|
||||
import { DomainModule } from '@app/domain';
|
||||
|
||||
@Module({
|
||||
@@ -37,6 +38,7 @@ import { DomainModule } from '@app/domain';
|
||||
MachineLearningProcessor,
|
||||
UserDeletionProcessor,
|
||||
StorageMigrationProcessor,
|
||||
BackgroundTaskProcessor,
|
||||
],
|
||||
})
|
||||
export class MicroservicesModule {}
|
||||
|
||||
@@ -1,50 +1,13 @@
|
||||
import { AssetType } from '@app/infra';
|
||||
import {
|
||||
IAssetUploadedJob,
|
||||
IMetadataExtractionJob,
|
||||
IThumbnailGenerationJob,
|
||||
IVideoTranscodeJob,
|
||||
QueueName,
|
||||
JobName,
|
||||
} from '@app/domain';
|
||||
import { InjectQueue, Process, Processor } from '@nestjs/bull';
|
||||
import { Job, Queue } from 'bull';
|
||||
import { IAssetUploadedJob, JobName, JobService, QueueName } from '@app/domain';
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Job } from 'bull';
|
||||
|
||||
@Processor(QueueName.ASSET_UPLOADED)
|
||||
export class AssetUploadedProcessor {
|
||||
constructor(
|
||||
@InjectQueue(QueueName.THUMBNAIL_GENERATION)
|
||||
private thumbnailGeneratorQueue: Queue<IThumbnailGenerationJob>,
|
||||
constructor(private jobService: JobService) {}
|
||||
|
||||
@InjectQueue(QueueName.METADATA_EXTRACTION)
|
||||
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
|
||||
|
||||
@InjectQueue(QueueName.VIDEO_CONVERSION)
|
||||
private videoConversionQueue: Queue<IVideoTranscodeJob>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Post processing uploaded asset to perform the following function if missing
|
||||
* 1. Generate JPEG Thumbnail
|
||||
* 2. Generate Webp Thumbnail
|
||||
* 3. EXIF extractor
|
||||
* 4. Reverse Geocoding
|
||||
*
|
||||
* @param job asset-uploaded
|
||||
*/
|
||||
@Process(JobName.ASSET_UPLOADED)
|
||||
async processUploadedVideo(job: Job<IAssetUploadedJob>) {
|
||||
const { asset, fileName } = job.data;
|
||||
|
||||
await this.thumbnailGeneratorQueue.add(JobName.GENERATE_JPEG_THUMBNAIL, { asset });
|
||||
|
||||
// Video Conversion
|
||||
if (asset.type == AssetType.VIDEO) {
|
||||
await this.videoConversionQueue.add(JobName.VIDEO_CONVERSION, { asset });
|
||||
await this.metadataExtractionQueue.add(JobName.EXTRACT_VIDEO_METADATA, { asset, fileName });
|
||||
} else {
|
||||
// Extract Metadata/Exif for Images - Currently the EXIF library on the web cannot extract EXIF for video yet
|
||||
await this.metadataExtractionQueue.add(JobName.EXIF_EXTRACTION, { asset, fileName });
|
||||
}
|
||||
await this.jobService.handleUploadedAsset(job);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { assetUtils } from '@app/common/utils';
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Job } from 'bull';
|
||||
import { JobName, QueueName } from '@app/domain';
|
||||
import { AssetEntity } from '@app/infra';
|
||||
import { AssetEntity } from '@app/infra/db/entities';
|
||||
|
||||
@Processor(QueueName.BACKGROUND_TASK)
|
||||
export class BackgroundTaskProcessor {
|
||||
@@ -235,6 +235,10 @@ export class MetadataExtractionProcessor {
|
||||
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
||||
const { asset, fileName } = job.data;
|
||||
|
||||
if (!asset.isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await new Promise<ffmpeg.FfprobeData>((resolve, reject) =>
|
||||
ffmpeg.ffprobe(asset.originalPath, (err, data) => {
|
||||
|
||||
@@ -301,6 +301,43 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/device-info": {
|
||||
"put": {
|
||||
"operationId": "upsertDeviceInfo",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpsertDeviceInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeviceInfoResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Device Info"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/oauth/mobile-redirect": {
|
||||
"get": {
|
||||
"operationId": "mobileRedirect",
|
||||
@@ -2505,43 +2542,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/device-info": {
|
||||
"put": {
|
||||
"operationId": "upsertDeviceInfo",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpsertDeviceInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeviceInfoResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Device Info"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/server-info": {
|
||||
"get": {
|
||||
"operationId": "getServerInfo",
|
||||
@@ -2707,7 +2707,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.1",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
@@ -2993,6 +2993,63 @@
|
||||
"redirectUri"
|
||||
]
|
||||
},
|
||||
"DeviceTypeEnum": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"IOS",
|
||||
"ANDROID",
|
||||
"WEB"
|
||||
]
|
||||
},
|
||||
"UpsertDeviceInfoDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deviceType": {
|
||||
"$ref": "#/components/schemas/DeviceTypeEnum"
|
||||
},
|
||||
"deviceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"isAutoBackup": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"deviceType",
|
||||
"deviceId"
|
||||
]
|
||||
},
|
||||
"DeviceInfoResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"deviceType": {
|
||||
"$ref": "#/components/schemas/DeviceTypeEnum"
|
||||
},
|
||||
"userId": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"isAutoBackup": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"deviceType",
|
||||
"userId",
|
||||
"deviceId",
|
||||
"createdAt",
|
||||
"isAutoBackup"
|
||||
]
|
||||
},
|
||||
"OAuthConfigDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3725,10 +3782,14 @@
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"duplicate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
"id",
|
||||
"duplicate"
|
||||
]
|
||||
},
|
||||
"DownloadFilesDto": {
|
||||
@@ -4261,63 +4322,6 @@
|
||||
"albumId"
|
||||
]
|
||||
},
|
||||
"DeviceTypeEnum": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"IOS",
|
||||
"ANDROID",
|
||||
"WEB"
|
||||
]
|
||||
},
|
||||
"UpsertDeviceInfoDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deviceType": {
|
||||
"$ref": "#/components/schemas/DeviceTypeEnum"
|
||||
},
|
||||
"deviceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"isAutoBackup": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"deviceType",
|
||||
"deviceId"
|
||||
]
|
||||
},
|
||||
"DeviceInfoResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"deviceType": {
|
||||
"$ref": "#/components/schemas/DeviceTypeEnum"
|
||||
},
|
||||
"userId": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"isAutoBackup": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"deviceType",
|
||||
"userId",
|
||||
"deviceId",
|
||||
"createdAt",
|
||||
"isAutoBackup"
|
||||
]
|
||||
},
|
||||
"ServerInfoResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
27
server/libs/domain/src/api-key/api-key.core.ts
Normal file
27
server/libs/domain/src/api-key/api-key.core.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
|
||||
@Injectable()
|
||||
export class APIKeyCore {
|
||||
constructor(private crypto: ICryptoRepository, private repository: IKeyRepository) {}
|
||||
|
||||
async validate(token: string): Promise<AuthUserDto | null> {
|
||||
const hashedToken = this.crypto.hashSha256(token);
|
||||
const keyEntity = await this.repository.getKey(hashedToken);
|
||||
if (keyEntity?.user) {
|
||||
const user = keyEntity.user;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid API key');
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,9 @@
|
||||
import { APIKeyEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { authStub, userEntityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { authStub, keyStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
import { APIKeyService } from './api-key.service';
|
||||
|
||||
const adminKey = Object.freeze({
|
||||
id: 1,
|
||||
name: 'My Key',
|
||||
key: 'my-api-key (hashed)',
|
||||
userId: authStub.admin.id,
|
||||
user: userEntityStub.admin,
|
||||
} as APIKeyEntity);
|
||||
|
||||
const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
|
||||
describe(APIKeyService.name, () => {
|
||||
let sut: APIKeyService;
|
||||
let keyMock: jest.Mocked<IKeyRepository>;
|
||||
@@ -28,10 +17,8 @@ describe(APIKeyService.name, () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new key', async () => {
|
||||
keyMock.create.mockResolvedValue(adminKey);
|
||||
|
||||
keyMock.create.mockResolvedValue(keyStub.admin);
|
||||
await sut.create(authStub.admin, { name: 'Test Key' });
|
||||
|
||||
expect(keyMock.create).toHaveBeenCalledWith({
|
||||
key: 'cmFuZG9tLWJ5dGVz (hashed)',
|
||||
name: 'Test Key',
|
||||
@@ -42,7 +29,7 @@ describe(APIKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should not require a name', async () => {
|
||||
keyMock.create.mockResolvedValue(adminKey);
|
||||
keyMock.create.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.create(authStub.admin, {});
|
||||
|
||||
@@ -66,7 +53,7 @@ describe(APIKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should update a key', async () => {
|
||||
keyMock.getById.mockResolvedValue(adminKey);
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.update(authStub.admin, 1, { name: 'New Name' });
|
||||
|
||||
@@ -84,7 +71,7 @@ describe(APIKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete a key', async () => {
|
||||
keyMock.getById.mockResolvedValue(adminKey);
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.delete(authStub.admin, 1);
|
||||
|
||||
@@ -102,7 +89,7 @@ describe(APIKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should get a key by id', async () => {
|
||||
keyMock.getById.mockResolvedValue(adminKey);
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.getById(authStub.admin, 1);
|
||||
|
||||
@@ -112,29 +99,11 @@ describe(APIKeyService.name, () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all the keys for a user', async () => {
|
||||
keyMock.getByUserId.mockResolvedValue([adminKey]);
|
||||
keyMock.getByUserId.mockResolvedValue([keyStub.admin]);
|
||||
|
||||
await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1);
|
||||
|
||||
expect(keyMock.getByUserId).toHaveBeenCalledWith(authStub.admin.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should throw an error for an invalid id', async () => {
|
||||
keyMock.getKey.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.validate(token)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)');
|
||||
});
|
||||
|
||||
it('should validate the token', async () => {
|
||||
keyMock.getKey.mockResolvedValue(adminKey);
|
||||
|
||||
await expect(sut.validate(token)).resolves.toEqual(authStub.admin);
|
||||
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
import { APIKeyCreateDto } from './dto/api-key-create.dto';
|
||||
import { APIKeyCreateResponseDto } from './response-dto/api-key-create-response.dto';
|
||||
@@ -55,22 +56,4 @@ export class APIKeyService {
|
||||
const keys = await this.repository.getByUserId(authUser.id);
|
||||
return keys.map(mapKey);
|
||||
}
|
||||
|
||||
async validate(token: string): Promise<AuthUserDto> {
|
||||
const hashedToken = this.crypto.hashSha256(token);
|
||||
const keyEntity = await this.repository.getKey(hashedToken);
|
||||
if (keyEntity?.user) {
|
||||
const user = keyEntity.user;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid API Key');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
|
||||
import { IUserTokenRepository, UserTokenCore } from '@app/domain';
|
||||
import cookieParser from 'cookie';
|
||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
||||
|
||||
export type JwtValidationResult = {
|
||||
status: boolean;
|
||||
@@ -37,11 +35,11 @@ export class AuthCore {
|
||||
let accessTokenCookie = '';
|
||||
|
||||
if (isSecure) {
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
} else {
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`;
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
}
|
||||
return [accessTokenCookie, authTypeCookie];
|
||||
}
|
||||
@@ -59,21 +57,4 @@ export class AuthCore {
|
||||
}
|
||||
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
|
||||
}
|
||||
|
||||
extractTokenFromHeader(headers: IncomingHttpHeaders) {
|
||||
if (!headers.authorization) {
|
||||
return this.extractTokenFromCookie(cookieParser.parse(headers.cookie || ''));
|
||||
}
|
||||
|
||||
const [type, accessToken] = headers.authorization.split(' ');
|
||||
if (type.toLowerCase() !== 'bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
extractTokenFromCookie(cookies: Record<string, string>) {
|
||||
return cookies?.[IMMICH_ACCESS_COOKIE] || null;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user