Merge branch 'immich-app:main' into feat/samsung-raw-and-fujifilm-raf
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
|
38
.github/workflows/build-mobile.yml
vendored
@ -2,54 +2,52 @@ name: Build Mobile
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
workflow_call:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-sign-android:
|
build-sign-android:
|
||||||
name: Build and sign Android
|
name: Build and sign Android
|
||||||
runs-on: ubuntu-latest
|
runs-on: macos-12
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- uses: actions/setup-java@v3
|
- uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: "zulu"
|
||||||
java-version: "12.x"
|
java-version: "12.x"
|
||||||
cache: 'gradle'
|
cache: "gradle"
|
||||||
|
|
||||||
- name: Setup Flutter SDK
|
- name: Setup Flutter SDK
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: "stable"
|
||||||
flutter-version: '3.3.10'
|
flutter-version: "3.3.10"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Create the Keystore
|
- name: Create the Keystore
|
||||||
|
|
||||||
env:
|
env:
|
||||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_SIGN_KEY_CONTENT }}
|
KEY_JKS: ${{ secrets.KEY_JKS }}
|
||||||
run: |
|
working-directory: ./mobile
|
||||||
# import keystore from secrets
|
run: echo $KEY_JKS | base64 -d > android/key.jks
|
||||||
echo $KEYSTORE_BASE64 | base64 -d > $RUNNER_TEMP/my_production.keystore
|
|
||||||
|
|
||||||
- name: Restore packages
|
- name: Get Packages
|
||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
- name: Build Android App Bundle
|
- name: Build Android App Bundle
|
||||||
working-directory: ./mobile
|
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
|
run: flutter build apk --release
|
||||||
|
|
||||||
- name: Sign Android App Bundle
|
|
||||||
working-directory: ./mobile
|
|
||||||
run: jarsigner -keystore $RUNNER_TEMP/my_production.keystore -storepass ${{ secrets.ANDROID_KEY_PASSWORD }} -keypass ${{ secrets.ANDROID_STORE_PASSWORD }} -sigalg SHA256withRSA -digestalg SHA-256 -signedjar build/app/outputs/apk/release/app-release-signed.apk build/app/outputs/apk/release/*.apk ${{ secrets.ALIAS }}
|
|
||||||
|
|
||||||
- name: Publish Android Artifact
|
- name: Publish Android Artifact
|
||||||
uses: actions/upload-artifact@v1
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: release-apk-signed
|
name: release-apk-signed
|
||||||
path: mobile/build/app/outputs/apk/release/app-release-signed.apk
|
path: mobile/build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
4
.github/workflows/docker.yml
vendored
@ -34,7 +34,7 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@v2.1.0
|
uses: docker/setup-qemu-action@v2.1.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- 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:
|
# Workaround to fix error:
|
||||||
# failed to push: failed to copy: io: read/write on closed pipe
|
# failed to push: failed to copy: io: read/write on closed pipe
|
||||||
# See https://github.com/docker/build-push-action/issues/761
|
# See https://github.com/docker/build-push-action/issues/761
|
||||||
@ -89,7 +89,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v3.3.0
|
uses: docker/build-push-action@v4.0.0
|
||||||
with:
|
with:
|
||||||
context: ${{ matrix.context }}
|
context: ${{ matrix.context }}
|
||||||
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
platforms: linux/arm/v7,linux/amd64,linux/arm64
|
||||||
|
29
.github/workflows/prepare-release.yml
vendored
@ -4,23 +4,28 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
serverBump:
|
serverBump:
|
||||||
description: 'Bump server version'
|
description: "Bump server version"
|
||||||
required: true
|
required: true
|
||||||
default: 'false'
|
default: "false"
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- false
|
- "false"
|
||||||
- minor
|
- minor
|
||||||
- patch
|
- patch
|
||||||
mobileBump:
|
mobileBump:
|
||||||
description: 'Bump mobile build number'
|
description: "Bump mobile build number"
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
build_mobile:
|
||||||
|
uses: ./.github/workflows/build-mobile.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
tag_release:
|
tag_release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: build_mobile
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@ -29,7 +34,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Bump version
|
- name: Bump version
|
||||||
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
|
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
|
||||||
|
|
||||||
- name: Commit and tag
|
- name: Commit and tag
|
||||||
uses: EndBug/add-and-commit@v9
|
uses: EndBug/add-and-commit@v9
|
||||||
with:
|
with:
|
||||||
@ -38,7 +43,12 @@ jobs:
|
|||||||
message: "Version ${{ env.IMMICH_VERSION }}"
|
message: "Version ${{ env.IMMICH_VERSION }}"
|
||||||
tag: ${{ env.IMMICH_VERSION }}
|
tag: ${{ env.IMMICH_VERSION }}
|
||||||
push: true
|
push: true
|
||||||
|
|
||||||
|
- name: Download APK
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: release-apk-signed
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
@ -49,3 +59,4 @@ jobs:
|
|||||||
files: |
|
files: |
|
||||||
docker/docker-compose.yml
|
docker/docker-compose.yml
|
||||||
docker/example.env
|
docker/example.env
|
||||||
|
*.apk
|
||||||
|
4
.github/workflows/test.yml
vendored
@ -44,7 +44,7 @@ jobs:
|
|||||||
name: Run mobile unit tests
|
name: Run mobile unit tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Setup Flutter SDK
|
- name: Setup Flutter SDK
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
@ -58,7 +58,7 @@ jobs:
|
|||||||
name: Run mobile end-to-end integration tests
|
name: Run mobile end-to-end integration tests
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-java@v3
|
- uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
distribution: 'adopt'
|
distribution: 'adopt'
|
||||||
|
3
.gitignore
vendored
@ -8,4 +8,5 @@ uploads
|
|||||||
coverage
|
coverage
|
||||||
|
|
||||||
mobile/gradle.properties
|
mobile/gradle.properties
|
||||||
mobile/openapi/pubspec.lock
|
mobile/openapi/pubspec.lock
|
||||||
|
mobile/*.jks
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m>
|
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m>
|
||||||
#
|
#
|
||||||
# examples:
|
# 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 -s minor -m # 1.0.0+50 => 1.1.0+51
|
||||||
# ./scripts/pump-version.sh -m # 1.0.0+50 => 1.0.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
|
while getopts 's:m:' flag; do
|
||||||
case "${flag}" in
|
case "${flag}" in
|
||||||
s) SERVER_PUMP=${OPTARG} ;;
|
s) SERVER_PUMP=${OPTARG} ;;
|
||||||
m) MOBILE_PUMP=${OPTARG} ;;
|
m) MOBILE_PUMP=${OPTARG} ;;
|
||||||
*) echo "Invalid args"
|
*)
|
||||||
exit 1 ;;
|
echo "Invalid args"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
@ -30,8 +32,11 @@ PATCH=$(echo $CURRENT_SERVER | cut -d '.' -f3)
|
|||||||
|
|
||||||
if [[ $SERVER_PUMP == "major" ]]; then
|
if [[ $SERVER_PUMP == "major" ]]; then
|
||||||
MAJOR=$((MAJOR + 1))
|
MAJOR=$((MAJOR + 1))
|
||||||
|
MINOR=0
|
||||||
|
PATCH=0
|
||||||
elif [[ $SERVER_PUMP == "minor" ]]; then
|
elif [[ $SERVER_PUMP == "minor" ]]; then
|
||||||
MINOR=$((MINOR + 1))
|
MINOR=$((MINOR + 1))
|
||||||
|
PATCH=0
|
||||||
elif [[ $SERVER_PUMP == "patch" ]]; then
|
elif [[ $SERVER_PUMP == "patch" ]]; then
|
||||||
PATCH=$((PATCH + 1))
|
PATCH=$((PATCH + 1))
|
||||||
elif [[ $SERVER_PUMP == "false" ]]; then
|
elif [[ $SERVER_PUMP == "false" ]]; then
|
||||||
@ -54,8 +59,6 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||||
|
|
||||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||||
@ -66,8 +69,6 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
|||||||
sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile
|
sed -i "s/version_number: \"$CURRENT_SERVER\"$/version_number: \"$NEXT_SERVER\"/" mobile/ios/fastlane/Fastfile
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
||||||
|
|
||||||
echo "Pumping Mobile: $CURRENT_MOBILE => $NEXT_MOBILE"
|
echo "Pumping Mobile: $CURRENT_MOBILE => $NEXT_MOBILE"
|
||||||
@ -76,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
|
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "IMMICH_VERSION=v$NEXT_SERVER" >> $GITHUB_ENV
|
echo "IMMICH_VERSION=v$NEXT_SERVER" >>$GITHUB_ENV
|
||||||
|
@ -57,21 +57,22 @@ android {
|
|||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
}
|
}
|
||||||
|
|
||||||
// signingConfigs {
|
signingConfigs {
|
||||||
// release {
|
release {
|
||||||
// keyAlias keystoreProperties['keyAlias']
|
def keyAliasVal = System.getenv("ALIAS")
|
||||||
// keyPassword keystoreProperties['keyPassword']
|
def keyPasswordVal = System.getenv("ANDROID_KEY_PASSWORD")
|
||||||
// storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
def storePasswordVal = System.getenv("ANDROID_STORE_PASSWORD")
|
||||||
// storePassword keystoreProperties['storePassword']
|
|
||||||
// }
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig signingConfigs.release
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
// signingConfig signingConfigs.release
|
|
||||||
signingConfig null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 9.3 KiB |
Before Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 9.3 KiB |
Before Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 73 KiB |
Before Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 144 KiB |
@ -1,6 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item>
|
<item android:drawable="?android:colorBackground" />
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
|
||||||
</item>
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
Before Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 73 KiB |
Before Width: | Height: | Size: 72 KiB |
Before Width: | Height: | Size: 144 KiB |
@ -1,6 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item>
|
<item android:drawable="@android:color/white" />
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
|
||||||
</item>
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
@ -1,19 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
|
||||||
<item name="android:forceDarkAllowed">false</item>
|
|
||||||
<item name="android:windowFullscreen">false</item>
|
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
|
||||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
|
||||||
</style>
|
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
|
||||||
This theme determines the color of the Android Window while your
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
@ -1,19 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
|
||||||
<item name="android:forceDarkAllowed">false</item>
|
|
||||||
<item name="android:windowFullscreen">false</item>
|
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
|
||||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
|
||||||
</style>
|
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
|
||||||
This theme determines the color of the Android Window while your
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
@ -5,9 +5,6 @@
|
|||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
Flutter draws its first frame -->
|
Flutter draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
<item name="android:forceDarkAllowed">false</item>
|
|
||||||
<item name="android:windowFullscreen">false</item>
|
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
|
||||||
</style>
|
</style>
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
This theme determines the color of the Android Window while your
|
This theme determines the color of the Android Window while your
|
||||||
|
@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 66,
|
"android.injected.version.code" => 67,
|
||||||
"android.injected.version.name" => "1.43.1",
|
"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')
|
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')
|
||||||
|
Before Width: | Height: | Size: 70 KiB |
@ -1,137 +0,0 @@
|
|||||||
flutter_native_splash:
|
|
||||||
# This package generates native code to customize Flutter's default white native splash screen
|
|
||||||
# with background color and splash image.
|
|
||||||
# Customize the parameters below, and run the following command in the terminal:
|
|
||||||
# flutter pub run flutter_native_splash:create
|
|
||||||
# To restore Flutter's default white splash screen, run the following command in the terminal:
|
|
||||||
# flutter pub run flutter_native_splash:remove
|
|
||||||
|
|
||||||
# color or background_image is the only required parameter. Use color to set the background
|
|
||||||
# of your splash screen to a solid color. Use background_image to set the background of your
|
|
||||||
# splash screen to a png image. This is useful for gradients. The image will be stretch to the
|
|
||||||
# size of the app. Only one parameter can be used, color and background_image cannot both be set.
|
|
||||||
background_image: "assets/immich-logo-no-outline.png"
|
|
||||||
|
|
||||||
# Optional parameters are listed below. To enable a parameter, uncomment the line by removing
|
|
||||||
# the leading # character.
|
|
||||||
|
|
||||||
# The image parameter allows you to specify an image used in the splash screen. It must be a
|
|
||||||
# png file and should be sized for 4x pixel density.
|
|
||||||
#image: assets/splash.png
|
|
||||||
|
|
||||||
# The branding property allows you to specify an image used as branding in the splash screen.
|
|
||||||
# It must be a png file. It is supported for Android, iOS and the Web. For Android 12,
|
|
||||||
# see the Android 12 section below.
|
|
||||||
#branding: assets/dart.png
|
|
||||||
|
|
||||||
# To position the branding image at the bottom of the screen you can use bottom, bottomRight,
|
|
||||||
# and bottomLeft. The default values is bottom if not specified or specified something else.
|
|
||||||
#branding_mode: bottom
|
|
||||||
|
|
||||||
# The color_dark, background_image_dark, image_dark, branding_dark are parameters that set the background
|
|
||||||
# and image when the device is in dark mode. If they are not specified, the app will use the
|
|
||||||
# parameters from above. If the image_dark parameter is specified, color_dark or
|
|
||||||
# background_image_dark must be specified. color_dark and background_image_dark cannot both be
|
|
||||||
# set.
|
|
||||||
#color_dark: "#042a49"
|
|
||||||
#background_image_dark: "assets/dark-background.png"
|
|
||||||
#image_dark: assets/splash-invert.png
|
|
||||||
#branding_dark: assets/dart_dark.png
|
|
||||||
|
|
||||||
# Android 12 handles the splash screen differently than previous versions. Please visit
|
|
||||||
# https://developer.android.com/guide/topics/ui/splash-screen
|
|
||||||
# Following are Android 12 specific parameter.
|
|
||||||
android_12:
|
|
||||||
# The image parameter sets the splash screen icon image. If this parameter is not specified,
|
|
||||||
# the app's launcher icon will be used instead.
|
|
||||||
# Please note that the splash screen will be clipped to a circle on the center of the screen.
|
|
||||||
# App icon with an icon background: This should be 960×960 pixels, and fit within a circle
|
|
||||||
# 640 pixels in diameter.
|
|
||||||
# App icon without an icon background: This should be 1152×1152 pixels, and fit within a circle
|
|
||||||
# 768 pixels in diameter.
|
|
||||||
image: assets/immich-logo-no-outline-android12.png
|
|
||||||
|
|
||||||
# Splash screen background color.
|
|
||||||
#color: "#42a5f5"
|
|
||||||
|
|
||||||
# App icon background color.
|
|
||||||
#icon_background_color: "#111111"
|
|
||||||
|
|
||||||
# The branding property allows you to specify an image used as branding in the splash screen.
|
|
||||||
#branding: assets/dart.png
|
|
||||||
|
|
||||||
# The image_dark, color_dark, icon_background_color_dark, and branding_dark set values that
|
|
||||||
# apply when the device is in dark mode. If they are not specified, the app will use the
|
|
||||||
# parameters from above.
|
|
||||||
#image_dark: assets/android12splash-invert.png
|
|
||||||
#color_dark: "#042a49"
|
|
||||||
#icon_background_color_dark: "#eeeeee"
|
|
||||||
|
|
||||||
# The android, ios and web parameters can be used to disable generating a splash screen on a given
|
|
||||||
# platform.
|
|
||||||
#android: false
|
|
||||||
#ios: false
|
|
||||||
#web: false
|
|
||||||
|
|
||||||
# Platform specific images can be specified with the following parameters, which will override
|
|
||||||
# the respective parameter. You may specify all, selected, or none of these parameters:
|
|
||||||
#color_android: "#42a5f5"
|
|
||||||
#color_dark_android: "#042a49"
|
|
||||||
#color_ios: "#42a5f5"
|
|
||||||
#color_dark_ios: "#042a49"
|
|
||||||
#color_web: "#42a5f5"
|
|
||||||
#color_dark_web: "#042a49"
|
|
||||||
#image_android: assets/splash-android.png
|
|
||||||
#image_dark_android: assets/splash-invert-android.png
|
|
||||||
#image_ios: assets/splash-ios.png
|
|
||||||
#image_dark_ios: assets/splash-invert-ios.png
|
|
||||||
#image_web: assets/splash-web.png
|
|
||||||
#image_dark_web: assets/splash-invert-web.png
|
|
||||||
#background_image_android: "assets/background-android.png"
|
|
||||||
#background_image_dark_android: "assets/dark-background-android.png"
|
|
||||||
#background_image_ios: "assets/background-ios.png"
|
|
||||||
#background_image_dark_ios: "assets/dark-background-ios.png"
|
|
||||||
#background_image_web: "assets/background-web.png"
|
|
||||||
#background_image_dark_web: "assets/dark-background-web.png"
|
|
||||||
#branding_android: assets/brand-android.png
|
|
||||||
#branding_dark_android: assets/dart_dark-android.png
|
|
||||||
#branding_ios: assets/brand-ios.png
|
|
||||||
#branding_dark_ios: assets/dart_dark-ios.png
|
|
||||||
|
|
||||||
# The position of the splash image can be set with android_gravity, ios_content_mode, and
|
|
||||||
# web_image_mode parameters. All default to center.
|
|
||||||
#
|
|
||||||
# android_gravity can be one of the following Android Gravity (see
|
|
||||||
# https://developer.android.com/reference/android/view/Gravity): bottom, center,
|
|
||||||
# center_horizontal, center_vertical, clip_horizontal, clip_vertical, end, fill, fill_horizontal,
|
|
||||||
# fill_vertical, left, right, start, or top.
|
|
||||||
#android_gravity: center
|
|
||||||
#
|
|
||||||
# ios_content_mode can be one of the following iOS UIView.ContentMode (see
|
|
||||||
# https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill,
|
|
||||||
# scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight,
|
|
||||||
# bottomLeft, or bottomRight.
|
|
||||||
#ios_content_mode: center
|
|
||||||
#
|
|
||||||
# web_image_mode can be one of the following modes: center, contain, stretch, and cover.
|
|
||||||
#web_image_mode: center
|
|
||||||
|
|
||||||
# The screen orientation can be set in Android with the android_screen_orientation parameter.
|
|
||||||
# Valid parameters can be found here:
|
|
||||||
# https://developer.android.com/guide/topics/manifest/activity-element#screen
|
|
||||||
#android_screen_orientation: sensorLandscape
|
|
||||||
|
|
||||||
# To hide the notification bar, use the fullscreen parameter. Has no effect in web since web
|
|
||||||
# has no notification bar. Defaults to false.
|
|
||||||
# NOTE: Unlike Android, iOS will not automatically show the notification bar when the app loads.
|
|
||||||
# To show the notification bar, add the following code to your Flutter app:
|
|
||||||
# WidgetsFlutterBinding.ensureInitialized();
|
|
||||||
# SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom, SystemUiOverlay.top]);
|
|
||||||
#fullscreen: true
|
|
||||||
|
|
||||||
# If you have changed the name(s) of your info.plist file(s), you can specify the filename(s)
|
|
||||||
# with the info_plist_files parameter. Remove only the # characters in the three lines below,
|
|
||||||
# do not remove any spaces:
|
|
||||||
#info_plist_files:
|
|
||||||
# - 'ios/Runner/Info-Debug.plist'
|
|
||||||
# - 'ios/Runner/Info-Release.plist'
|
|
@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "LaunchImage.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage.png",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "LaunchImage@2x.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@2x.png",
|
||||||
"scale" : "2x"
|
"scale" : "2x"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "LaunchImage@3x.png",
|
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@3x.png",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
"author" : "xcode",
|
"version" : 1,
|
||||||
"version" : 1
|
"author" : "xcode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 68 B |
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 68 B |
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 68 B |
@ -16,19 +16,13 @@
|
|||||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
|
|
||||||
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
|
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
|
|
||||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
|
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
|
|
||||||
</constraints>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
@ -39,6 +33,5 @@
|
|||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="LaunchImage" width="168" height="185"/>
|
<image name="LaunchImage" width="168" height="185"/>
|
||||||
<image name="LaunchBackground" width="1" height="1"/>
|
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
@ -1,97 +1,105 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Immich</string>
|
<string>Immich</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>immich_mobile</string>
|
<string>immich_mobile</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.42.0</string>
|
<string>1.43.0</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>79</string>
|
<string>82</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true />
|
||||||
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
|
||||||
<true/>
|
<true />
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true />
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSLocationAlwaysUsageDescription</key>
|
<key>NSLocationAlwaysUsageDescription</key>
|
||||||
<string>Enable location setting to show position of assets on map</string>
|
<string>Enable location setting to show position of assets on map</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
|
||||||
<string>Enable location setting to show position of assets on map</string>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<string>Enable location setting to show position of assets on map</string>
|
||||||
<string>We need to manage backup your photos album</string>
|
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>We need to manage backup your photos album</string>
|
<string>We need to manage backup your photos album</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
|
||||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<string>We need to manage backup your photos album</string>
|
||||||
<string>We need to access the microphone to let you take beautiful video using this app</string>
|
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>LaunchScreen</string>
|
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
|
||||||
<string>Main</string>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<string>We need to access the microphone to let you take beautiful video using this app</string>
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>LaunchScreen</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<key>UIMainStoryboardFile</key>
|
||||||
</array>
|
<string>Main</string>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
</array>
|
||||||
</array>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<array>
|
||||||
<true/>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<key>io.flutter.embedded_views_preview</key>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<true/>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
<false/>
|
</array>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<key>LSApplicationQueriesSchemes</key>
|
<true />
|
||||||
<array>
|
<key>io.flutter.embedded_views_preview</key>
|
||||||
<string>https</string>
|
<true />
|
||||||
</array>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<key>CFBundleLocalizations</key>
|
<false />
|
||||||
<array>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<string>cs</string>
|
<true />
|
||||||
<string>da</string>
|
|
||||||
<string>de</string>
|
|
||||||
<string>en</string>
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
<string>es</string>
|
<array>
|
||||||
<string>fi</string>
|
<string>https</string>
|
||||||
<string>fr</string>
|
</array>
|
||||||
<string>it</string>
|
|
||||||
<string>ja</string>
|
<key>CFBundleLocalizations</key>
|
||||||
<string>ko</string>
|
<array>
|
||||||
<string>nl</string>
|
<string>cs</string>
|
||||||
<string>pl</string>
|
<string>da</string>
|
||||||
<string>pt</string>
|
<string>de</string>
|
||||||
<string>ru</string>
|
<string>en</string>
|
||||||
<string>sk</string>
|
<string>es</string>
|
||||||
<string>zh</string>
|
<string>fi</string>
|
||||||
</array>
|
<string>fr</string>
|
||||||
<key>UIStatusBarHidden</key>
|
<string>it</string>
|
||||||
<false/>
|
<string>ja</string>
|
||||||
</dict>
|
<string>ko</string>
|
||||||
</plist>
|
<string>nl</string>
|
||||||
|
<string>pl</string>
|
||||||
|
<string>pt</string>
|
||||||
|
<string>ru</string>
|
||||||
|
<string>sk</string>
|
||||||
|
<string>zh</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
@ -19,7 +19,7 @@ platform :ios do
|
|||||||
desc "iOS Beta"
|
desc "iOS Beta"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.43.1"
|
version_number: "1.44.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -248,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) {
|
Widget buildBody(AlbumResponseDto albumInfo) {
|
||||||
return GestureDetector(
|
return WillPopScope(
|
||||||
onTap: () {
|
onWillPop: onWillPop,
|
||||||
titleFocusNode.unfocus();
|
child: GestureDetector(
|
||||||
},
|
onTap: () {
|
||||||
child: DraggableScrollbar.semicircle(
|
titleFocusNode.unfocus();
|
||||||
backgroundColor: Theme.of(context).hintColor,
|
},
|
||||||
controller: scrollController,
|
child: DraggableScrollbar.semicircle(
|
||||||
heightScrollThumb: 48.0,
|
backgroundColor: Theme.of(context).hintColor,
|
||||||
child: CustomScrollView(
|
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
slivers: [
|
heightScrollThumb: 48.0,
|
||||||
buildHeader(albumInfo),
|
child: CustomScrollView(
|
||||||
SliverPersistentHeader(
|
controller: scrollController,
|
||||||
pinned: true,
|
slivers: [
|
||||||
delegate: ImmichSliverPersistentAppBarDelegate(
|
buildHeader(albumInfo),
|
||||||
minHeight: 50,
|
SliverPersistentHeader(
|
||||||
maxHeight: 50,
|
pinned: true,
|
||||||
child: Container(
|
delegate: ImmichSliverPersistentAppBarDelegate(
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
minHeight: 50,
|
||||||
child: buildControlButton(albumInfo),
|
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:auto_route/auto_route.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
@ -9,14 +12,21 @@ import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
|
|||||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
import 'package:immich_mobile/modules/asset_viewer/providers/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/exif_bottom_sheet.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.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/asset_viewer/views/video_viewer_page.dart';
|
||||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/modules/home/ui/delete_diaglog.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/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.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/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.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
|
// ignore: must_be_immutable
|
||||||
class GalleryViewerPage extends HookConsumerWidget {
|
class GalleryViewerPage extends HookConsumerWidget {
|
||||||
@ -40,7 +50,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
final isZoomed = useState<bool>(false);
|
final isZoomed = useState<bool>(false);
|
||||||
final indexOfAsset = useState(assetList.indexOf(asset));
|
final indexOfAsset = useState(assetList.indexOf(asset));
|
||||||
final isPlayingMotionVideo = useState(false);
|
final isPlayingMotionVideo = useState(false);
|
||||||
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
|
late Offset localPosition;
|
||||||
|
final authToken = 'Bearer ${box.get(accessTokenKey)}';
|
||||||
|
|
||||||
PageController controller =
|
PageController controller =
|
||||||
PageController(initialPage: assetList.indexOf(asset));
|
PageController(initialPage: assetList.indexOf(asset));
|
||||||
@ -57,7 +68,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
getAssetExif() async {
|
void getAssetExif() async {
|
||||||
if (assetList[indexOfAsset.value].isRemote) {
|
if (assetList[indexOfAsset.value].isRemote) {
|
||||||
assetDetail = await ref
|
assetDetail = await ref
|
||||||
.watch(assetServiceProvider)
|
.watch(assetServiceProvider)
|
||||||
@ -68,27 +79,96 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void showInfo() {
|
/// Thumbnail image of a remote asset. Required asset.remote != null
|
||||||
showModalBottomSheet(
|
ImageProvider remoteThumbnailImageProvider(Asset asset, api.ThumbnailFormat type) {
|
||||||
shape: RoundedRectangleBorder(
|
return CachedNetworkImageProvider(
|
||||||
borderRadius: BorderRadius.circular(15.0),
|
getThumbnailUrl(
|
||||||
|
asset.remote!,
|
||||||
|
type: type,
|
||||||
),
|
),
|
||||||
barrierColor: Colors.transparent,
|
cacheKey: getThumbnailCacheKey(
|
||||||
backgroundColor: Colors.transparent,
|
asset.remote!,
|
||||||
isScrollControlled: true,
|
type: type,
|
||||||
context: context,
|
),
|
||||||
builder: (context) {
|
headers: {"Authorization": authToken},
|
||||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
//make isZoomed listener call instead
|
/// Original (large) image of a remote asset. Required asset.remote != null
|
||||||
void isZoomedMethod() {
|
ImageProvider originalImageProvider(Asset asset) {
|
||||||
if (isZoomedListener.value) {
|
return CachedNetworkImageProvider(
|
||||||
isZoomed.value = true;
|
getImageUrl(asset.remote!),
|
||||||
} else {
|
cacheKey: getImageCacheKey(asset.remote!),
|
||||||
isZoomed.value = false;
|
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!);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,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(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
appBar: TopControlAppBar(
|
appBar: TopControlAppBar(
|
||||||
@ -150,61 +252,93 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]),
|
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]),
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: PageView.builder(
|
child: PhotoViewGallery.builder(
|
||||||
controller: controller,
|
scaleStateChangedCallback: (state) => isZoomed.value = state != PhotoViewScaleState.initial,
|
||||||
pageSnapping: true,
|
pageController: controller,
|
||||||
physics: isZoomed.value
|
scrollPhysics: isZoomed.value
|
||||||
? const NeverScrollableScrollPhysics()
|
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
||||||
: const BouncingScrollPhysics(),
|
: (Platform.isIOS
|
||||||
|
? const BouncingScrollPhysics() // Use bouncing physics for iOS
|
||||||
|
: const ClampingScrollPhysics() // Use heavy physics for Android
|
||||||
|
),
|
||||||
itemCount: assetList.length,
|
itemCount: assetList.length,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
onPageChanged: (value) {
|
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;
|
indexOfAsset.value = value;
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
},
|
},
|
||||||
itemBuilder: (context, index) {
|
loadingBuilder: isLoadPreview.value ? (context, event) {
|
||||||
getAssetExif();
|
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) {
|
return CachedNetworkImage(
|
||||||
if (isPlayingMotionVideo.value) {
|
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.JPEG),
|
||||||
return VideoViewerPage(
|
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.JPEG),
|
||||||
asset: assetList[index],
|
httpHeaders: { 'Authorization': authToken },
|
||||||
isMotionVideo: true,
|
fit: BoxFit.contain,
|
||||||
onVideoEnded: () {
|
placeholder: (_, __) => webPThumbnail,
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return GestureDetector(
|
return Image(
|
||||||
onVerticalDragUpdate: (details) {
|
image: localThumbnailImageProvider(asset),
|
||||||
const int sensitivity = 15;
|
fit: BoxFit.contain,
|
||||||
if (details.delta.dy > sensitivity) {
|
);
|
||||||
// swipe down
|
}
|
||||||
AutoRouter.of(context).pop();
|
} : null,
|
||||||
} else if (details.delta.dy < -sensitivity) {
|
builder: (context, index) {
|
||||||
// swipe up
|
getAssetExif();
|
||||||
showInfo();
|
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
|
||||||
}
|
// Show photo
|
||||||
},
|
final ImageProvider provider;
|
||||||
child: Hero(
|
if (assetList[index].isLocal) {
|
||||||
tag: assetList[index].id,
|
provider = localImageProvider(assetList[index]);
|
||||||
child: VideoViewerPage(
|
} else {
|
||||||
asset: assetList[index],
|
if (isLoadOriginal.value) {
|
||||||
isMotionVideo: false,
|
provider = originalImageProvider(assetList[index]);
|
||||||
onVideoEnded: () {},
|
} 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -214,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 {
|
class BackupService {
|
||||||
|
final httpClient = http.Client();
|
||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
|
|
||||||
BackupService(this._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) {
|
if (response.statusCode == 200) {
|
||||||
// asset is a duplicate (already exists on the server)
|
// asset is a duplicate (already exists on the server)
|
||||||
@ -334,7 +336,6 @@ class BackupService {
|
|||||||
|
|
||||||
Future<MultipartFile?> _getLivePhotoFile(AssetEntity entity) async {
|
Future<MultipartFile?> _getLivePhotoFile(AssetEntity entity) async {
|
||||||
var motionFilePath = await entity.getMediaUrl();
|
var motionFilePath = await entity.getMediaUrl();
|
||||||
// var motionFilePath = '/var/mobile/Media/DCIM/103APPLE/IMG_3371.MOV'
|
|
||||||
|
|
||||||
if (motionFilePath != null) {
|
if (motionFilePath != null) {
|
||||||
var validPath = motionFilePath.replaceAll('file://', '');
|
var validPath = motionFilePath.replaceAll('file://', '');
|
||||||
|
@ -200,34 +200,46 @@ class HomePage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SafeArea(
|
Future<bool> onWillPop() async {
|
||||||
bottom: !multiselectEnabled.state,
|
if (multiselectEnabled.state) {
|
||||||
top: true,
|
selectionEnabledHook.value = false;
|
||||||
child: Stack(
|
return false;
|
||||||
children: [
|
}
|
||||||
ref.watch(assetProvider).renderList == null ||
|
|
||||||
ref.watch(assetProvider).allAssets.isEmpty
|
return true;
|
||||||
? buildLoadingIndicator()
|
}
|
||||||
: ImmichAssetGrid(
|
|
||||||
renderList: ref.watch(assetProvider).renderList!,
|
return WillPopScope(
|
||||||
allAssets: ref.watch(assetProvider).allAssets,
|
onWillPop: onWillPop,
|
||||||
assetsPerRow: appSettingService
|
child: SafeArea(
|
||||||
.getSetting(AppSettingsEnum.tilesPerRow),
|
bottom: !multiselectEnabled.state,
|
||||||
showStorageIndicator: appSettingService
|
top: true,
|
||||||
.getSetting(AppSettingsEnum.storageIndicator),
|
child: Stack(
|
||||||
listener: selectionListener,
|
children: [
|
||||||
selectionActive: selectionEnabledHook.value,
|
ref.watch(assetProvider).renderList == null ||
|
||||||
),
|
ref.watch(assetProvider).allAssets.isEmpty
|
||||||
if (selectionEnabledHook.value)
|
? buildLoadingIndicator()
|
||||||
ControlBottomAppBar(
|
: ImmichAssetGrid(
|
||||||
onShare: onShareAssets,
|
renderList: ref.watch(assetProvider).renderList!,
|
||||||
onDelete: onDelete,
|
allAssets: ref.watch(assetProvider).allAssets,
|
||||||
onAddToAlbum: onAddToAlbum,
|
assetsPerRow: appSettingService
|
||||||
albums: albums,
|
.getSetting(AppSettingsEnum.tilesPerRow),
|
||||||
sharedAlbums: sharedAlbums,
|
showStorageIndicator: appSettingService
|
||||||
onCreateNewAlbum: onCreateNewAlbum,
|
.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/select_user_for_sharing_page.dart';
|
||||||
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
|
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
|
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
|
||||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||||
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
|
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
|
||||||
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
|
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
|
||||||
@ -52,7 +51,6 @@ part 'router.gr.dart';
|
|||||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||||
),
|
),
|
||||||
AutoRoute(page: GalleryViewerPage, guards: [AuthGuard]),
|
AutoRoute(page: GalleryViewerPage, guards: [AuthGuard]),
|
||||||
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
|
|
||||||
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
||||||
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
|
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
|
||||||
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
|
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
|
||||||
|
@ -48,21 +48,6 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
child: GalleryViewerPage(
|
child: GalleryViewerPage(
|
||||||
key: args.key, assetList: args.assetList, asset: args.asset));
|
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) {
|
VideoViewerRoute.name: (routeData) {
|
||||||
final args = routeData.argsAs<VideoViewerRouteArgs>();
|
final args = routeData.argsAs<VideoViewerRouteArgs>();
|
||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
@ -204,8 +189,6 @@ class _$AppRouter extends RootStackRouter {
|
|||||||
]),
|
]),
|
||||||
RouteConfig(GalleryViewerRoute.name,
|
RouteConfig(GalleryViewerRoute.name,
|
||||||
path: '/gallery-viewer-page', guards: [authGuard]),
|
path: '/gallery-viewer-page', guards: [authGuard]),
|
||||||
RouteConfig(ImageViewerRoute.name,
|
|
||||||
path: '/image-viewer-page', guards: [authGuard]),
|
|
||||||
RouteConfig(VideoViewerRoute.name,
|
RouteConfig(VideoViewerRoute.name,
|
||||||
path: '/video-viewer-page', guards: [authGuard]),
|
path: '/video-viewer-page', guards: [authGuard]),
|
||||||
RouteConfig(BackupControllerRoute.name,
|
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
|
/// generated route for
|
||||||
/// [VideoViewerPage]
|
/// [VideoViewerPage]
|
||||||
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
||||||
|
@ -22,7 +22,7 @@ class ShareService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> shareAssets(List<Asset> assets) async {
|
Future<void> shareAssets(List<Asset> assets) async {
|
||||||
final downloadedFilePaths = assets.map((asset) async {
|
final downloadedXFiles = assets.map<Future<XFile>>((asset) async {
|
||||||
if (asset.isRemote) {
|
if (asset.isRemote) {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final fileName = basename(asset.remote!.originalPath);
|
final fileName = basename(asset.remote!.originalPath);
|
||||||
@ -33,16 +33,16 @@ class ShareService {
|
|||||||
isWeb: false,
|
isWeb: false,
|
||||||
);
|
);
|
||||||
tempFile.writeAsBytesSync(res.bodyBytes);
|
tempFile.writeAsBytesSync(res.bodyBytes);
|
||||||
return tempFile.path;
|
return XFile(tempFile.path);
|
||||||
} else {
|
} else {
|
||||||
File? f = await asset.local!.file;
|
File? f = await asset.local!.file;
|
||||||
return f!.path;
|
return XFile(f!.path);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
Share.shareFiles(
|
Share.shareXFiles(
|
||||||
await Future.wait(downloadedFilePaths),
|
await Future.wait(downloadedXFiles),
|
||||||
sharePositionOrigin: Rect.zero,
|
sharePositionOrigin: Rect.zero,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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
@ -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
@ -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
@ -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
@ -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;
|
||||||
|
}
|
@ -57,12 +57,30 @@ class SplashScreenPage extends HookConsumerWidget {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return const Scaffold(
|
return Scaffold(
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Image(
|
child: Column(
|
||||||
image: AssetImage('assets/immich-logo-no-outline.png'),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
width: 200,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
filterQuality: FilterQuality.high,
|
children: [
|
||||||
|
const Image(
|
||||||
|
image: AssetImage('assets/immich-logo-no-outline.png'),
|
||||||
|
width: 200,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Text(
|
||||||
|
'IMMICH',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SnowburstOne',
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 48,
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -8,7 +8,4 @@ create_app_icon:
|
|||||||
flutter pub run flutter_launcher_icons:main
|
flutter pub run flutter_launcher_icons:main
|
||||||
|
|
||||||
build_release_android:
|
build_release_android:
|
||||||
flutter build appbundle
|
flutter build appbundle
|
||||||
|
|
||||||
create_splash:
|
|
||||||
flutter pub run flutter_native_splash:create
|
|
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:
|
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
|
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
1
mobile/openapi/doc/AssetFileUploadResponseDto.md
generated
@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
|
|||||||
Name | Type | Description | Notes
|
Name | Type | Description | Notes
|
||||||
------------ | ------------- | ------------- | -------------
|
------------ | ------------- | ------------- | -------------
|
||||||
**id** | **String** | |
|
**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)
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
@ -14,25 +14,31 @@ class AssetFileUploadResponseDto {
|
|||||||
/// Returns a new [AssetFileUploadResponseDto] instance.
|
/// Returns a new [AssetFileUploadResponseDto] instance.
|
||||||
AssetFileUploadResponseDto({
|
AssetFileUploadResponseDto({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
required this.duplicate,
|
||||||
});
|
});
|
||||||
|
|
||||||
String id;
|
String id;
|
||||||
|
|
||||||
|
bool duplicate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AssetFileUploadResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AssetFileUploadResponseDto &&
|
||||||
other.id == id;
|
other.id == id &&
|
||||||
|
other.duplicate == duplicate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(id.hashCode);
|
(id.hashCode) +
|
||||||
|
(duplicate.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'AssetFileUploadResponseDto[id=$id]';
|
String toString() => 'AssetFileUploadResponseDto[id=$id, duplicate=$duplicate]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'id'] = this.id;
|
json[r'id'] = this.id;
|
||||||
|
json[r'duplicate'] = this.duplicate;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,6 +62,7 @@ class AssetFileUploadResponseDto {
|
|||||||
|
|
||||||
return AssetFileUploadResponseDto(
|
return AssetFileUploadResponseDto(
|
||||||
id: mapValueOfType<String>(json, r'id')!,
|
id: mapValueOfType<String>(json, r'id')!,
|
||||||
|
duplicate: mapValueOfType<bool>(json, r'duplicate')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -106,6 +113,7 @@ class AssetFileUploadResponseDto {
|
|||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'id',
|
'id',
|
||||||
|
'duplicate',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,6 +21,11 @@ void main() {
|
|||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// bool duplicate
|
||||||
|
test('to test the property `duplicate`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -210,7 +210,7 @@ packages:
|
|||||||
name: cross_file
|
name: cross_file
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.3+1"
|
version: "0.3.3+2"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -239,6 +239,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.3"
|
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:
|
easy_localization:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -325,7 +332,7 @@ packages:
|
|||||||
name: flutter_launcher_icons
|
name: flutter_launcher_icons
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.9.3"
|
version: "0.9.2"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@ -345,13 +352,6 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.14.0"
|
version: "0.14.0"
|
||||||
flutter_native_splash:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description:
|
|
||||||
name: flutter_native_splash
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.17"
|
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -457,7 +457,7 @@ packages:
|
|||||||
name: html
|
name: html
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.1"
|
version: "0.15.0"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -480,12 +480,12 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.1"
|
version: "4.0.1"
|
||||||
image:
|
image:
|
||||||
dependency: "direct overridden"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.12"
|
version: "3.2.0"
|
||||||
image_picker:
|
image_picker:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -764,13 +764,6 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.0"
|
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:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -868,42 +861,14 @@ packages:
|
|||||||
name: share_plus
|
name: share_plus
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.10"
|
version: "6.3.0"
|
||||||
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"
|
|
||||||
share_plus_platform_interface:
|
share_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: share_plus_platform_interface
|
name: share_plus_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
version: "3.2.0"
|
||||||
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"
|
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1126,13 +1091,6 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.1"
|
version: "0.3.1"
|
||||||
universal_io:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: universal_io
|
|
||||||
url: "https://pub.dartlang.org"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.4"
|
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -1174,14 +1132,14 @@ packages:
|
|||||||
name: url_launcher_platform_interface
|
name: url_launcher_platform_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.5"
|
version: "2.1.1"
|
||||||
url_launcher_web:
|
url_launcher_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_web
|
name: url_launcher_web
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.11"
|
version: "2.0.14"
|
||||||
url_launcher_windows:
|
url_launcher_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1279,7 +1237,7 @@ packages:
|
|||||||
name: wakelock_windows
|
name: wakelock_windows
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
version: "0.2.1"
|
||||||
watcher:
|
watcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1307,7 +1265,7 @@ packages:
|
|||||||
name: win32
|
name: win32
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.7.0"
|
version: "3.1.3"
|
||||||
wkt_parser:
|
wkt_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -23,7 +23,6 @@ dependencies:
|
|||||||
video_player: ^2.2.18
|
video_player: ^2.2.18
|
||||||
chewie: ^1.3.5
|
chewie: ^1.3.5
|
||||||
badges: ^2.0.2
|
badges: ^2.0.2
|
||||||
photo_view: ^0.14.0
|
|
||||||
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
||||||
flutter_map: ^0.14.0
|
flutter_map: ^0.14.0
|
||||||
flutter_udid: ^2.0.0
|
flutter_udid: ^2.0.0
|
||||||
@ -32,7 +31,7 @@ dependencies:
|
|||||||
http: 0.13.4
|
http: 0.13.4
|
||||||
cancellation_token_http: ^1.1.0
|
cancellation_token_http: ^1.1.0
|
||||||
easy_localization: ^3.0.1
|
easy_localization: ^3.0.1
|
||||||
share_plus: ^4.0.10
|
share_plus: ^6.3.0
|
||||||
flutter_displaymode: ^0.4.0
|
flutter_displaymode: ^0.4.0
|
||||||
scrollable_positioned_list: ^0.3.4
|
scrollable_positioned_list: ^0.3.4
|
||||||
path: ^1.8.1
|
path: ^1.8.1
|
||||||
@ -41,6 +40,7 @@ dependencies:
|
|||||||
collection: ^1.16.0
|
collection: ^1.16.0
|
||||||
http_parser: ^4.0.1
|
http_parser: ^4.0.1
|
||||||
flutter_web_auth: ^0.5.0
|
flutter_web_auth: ^0.5.0
|
||||||
|
easy_image_viewer: ^1.2.0
|
||||||
|
|
||||||
openapi:
|
openapi:
|
||||||
path: openapi
|
path: openapi
|
||||||
@ -56,14 +56,10 @@ dev_dependencies:
|
|||||||
hive_generator: ^1.1.2
|
hive_generator: ^1.1.2
|
||||||
build_runner: ^2.2.1
|
build_runner: ^2.2.1
|
||||||
auto_route_generator: ^5.0.2
|
auto_route_generator: ^5.0.2
|
||||||
flutter_launcher_icons: ^0.9.2
|
flutter_launcher_icons: "^0.9.2"
|
||||||
flutter_native_splash: ^2.2.17
|
|
||||||
integration_test:
|
integration_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
dependency_overrides:
|
|
||||||
image: ^4.0.12
|
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
|
@ -101,7 +101,7 @@ export class AlbumService {
|
|||||||
const album = await this._getAlbum({ authUser, albumId });
|
const album = await this._getAlbum({ authUser, albumId });
|
||||||
|
|
||||||
for (const sharedLink of album.sharedLinks) {
|
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);
|
await this._albumRepository.delete(album);
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { SearchPropertiesDto } from './dto/search-properties.dto';
|
import { SearchPropertiesDto } from './dto/search-properties.dto';
|
||||||
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
||||||
import { AssetEntity, AssetType } from '@app/infra';
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm/repository/Repository';
|
import { Repository } from 'typeorm/repository/Repository';
|
||||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
|
||||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||||
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-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';
|
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';
|
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||||
|
|
||||||
export interface IAssetRepository {
|
export interface IAssetRepository {
|
||||||
create(
|
get(id: string): Promise<AssetEntity | null>;
|
||||||
createAssetDto: CreateAssetDto,
|
create(asset: Omit<AssetEntity, 'id'>): Promise<AssetEntity>;
|
||||||
ownerId: string,
|
remove(asset: AssetEntity): Promise<void>;
|
||||||
originalPath: string,
|
|
||||||
mimeType: string,
|
|
||||||
isVisible: boolean,
|
|
||||||
checksum?: Buffer,
|
|
||||||
livePhotoAssetEntity?: AssetEntity,
|
|
||||||
): Promise<AssetEntity>;
|
|
||||||
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
|
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
|
||||||
getAll(): Promise<AssetEntity[]>;
|
getAll(): Promise<AssetEntity[]>;
|
||||||
getAllVideos(): Promise<AssetEntity[]>;
|
getAllVideos(): Promise<AssetEntity[]>;
|
||||||
@ -282,44 +276,16 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
get(id: string): Promise<AssetEntity | null> {
|
||||||
* Create new asset information in database
|
return this.assetRepository.findOne({ where: { id } });
|
||||||
* @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;
|
|
||||||
|
|
||||||
const createdAsset = await this.assetRepository.save(asset);
|
async create(asset: Omit<AssetEntity, 'id'>): Promise<AssetEntity> {
|
||||||
|
return this.assetRepository.save(asset);
|
||||||
|
}
|
||||||
|
|
||||||
if (!createdAsset) {
|
async remove(asset: AssetEntity): Promise<void> {
|
||||||
throw new BadRequestException('Asset not created');
|
await this.assetRepository.remove(asset);
|
||||||
}
|
|
||||||
return createdAsset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -19,11 +19,9 @@ import {
|
|||||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||||
import { AssetService } from './asset.service';
|
import { AssetService } from './asset.service';
|
||||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||||
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
|
|
||||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||||
import { ServeFileDto } from './dto/serve-file.dto';
|
import { ServeFileDto } from './dto/serve-file.dto';
|
||||||
import { Response as Res } from 'express';
|
import { Response as Res } from 'express';
|
||||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
|
||||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||||
import { SearchAssetDto } from './dto/search-asset.dto';
|
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||||
@ -33,9 +31,9 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re
|
|||||||
import { AssetResponseDto } from '@app/domain';
|
import { AssetResponseDto } from '@app/domain';
|
||||||
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
|
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
|
||||||
import { AssetFileUploadDto } from './dto/asset-file-upload.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 { 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 { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
|
||||||
import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
|
import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
|
||||||
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.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 { SharedLinkResponseDto } from '@app/domain';
|
||||||
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
|
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
|
||||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||||
|
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiTags('Asset')
|
@ApiTags('Asset')
|
||||||
@Controller('asset')
|
@Controller('asset')
|
||||||
export class AssetController {
|
export class AssetController {
|
||||||
constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {}
|
constructor(private assetService: AssetService) {}
|
||||||
|
|
||||||
@Authenticated({ isShared: true })
|
@Authenticated({ isShared: true })
|
||||||
@Post('upload')
|
@Post('upload')
|
||||||
@ -81,13 +80,22 @@ export class AssetController {
|
|||||||
async uploadFile(
|
async uploadFile(
|
||||||
@GetAuthUser() authUser: AuthUserDto,
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
@UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] },
|
@UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] },
|
||||||
@Body(ValidationPipe) createAssetDto: CreateAssetDto,
|
@Body(ValidationPipe) dto: CreateAssetDto,
|
||||||
@Response({ passthrough: true }) res: Res,
|
@Response({ passthrough: true }) res: Res,
|
||||||
): Promise<AssetFileUploadResponseDto> {
|
): Promise<AssetFileUploadResponseDto> {
|
||||||
const originalAssetData = files.assetData[0];
|
const file = mapToUploadFile(files.assetData[0]);
|
||||||
const livePhotoAssetData = files.livePhotoData?.[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 })
|
@Authenticated({ isShared: true })
|
||||||
@ -276,37 +284,10 @@ export class AssetController {
|
|||||||
@Delete('/')
|
@Delete('/')
|
||||||
async deleteAsset(
|
async deleteAsset(
|
||||||
@GetAuthUser() authUser: AuthUserDto,
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
@Body(ValidationPipe) assetIds: DeleteAssetDto,
|
@Body(ValidationPipe) dto: DeleteAssetDto,
|
||||||
): Promise<DeleteAssetResponseDto[]> {
|
): Promise<DeleteAssetResponseDto[]> {
|
||||||
await this.assetService.checkAssetsAccess(authUser, assetIds.ids, true);
|
await this.assetService.checkAssetsAccess(authUser, dto.ids, true);
|
||||||
|
return this.assetService.deleteAll(authUser, dto);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
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 { AssetController } from './asset.controller';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AssetEntity } from '@app/infra';
|
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 { CommunicationModule } from '../communication/communication.module';
|
||||||
import { AssetRepository, IAssetRepository } from './asset-repository';
|
import { AssetRepository, IAssetRepository } from './asset-repository';
|
||||||
import { DownloadModule } from '../../modules/download/download.module';
|
import { DownloadModule } from '../../modules/download/download.module';
|
||||||
@ -21,14 +19,13 @@ const ASSET_REPOSITORY_PROVIDER = {
|
|||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([AssetEntity]),
|
TypeOrmModule.forFeature([AssetEntity]),
|
||||||
CommunicationModule,
|
CommunicationModule,
|
||||||
BackgroundTaskModule,
|
|
||||||
DownloadModule,
|
DownloadModule,
|
||||||
TagModule,
|
TagModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
forwardRef(() => AlbumModule),
|
forwardRef(() => AlbumModule),
|
||||||
],
|
],
|
||||||
controllers: [AssetController],
|
controllers: [AssetController],
|
||||||
providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER],
|
providers: [AssetService, ASSET_REPOSITORY_PROVIDER],
|
||||||
exports: [ASSET_REPOSITORY_PROVIDER],
|
exports: [ASSET_REPOSITORY_PROVIDER],
|
||||||
})
|
})
|
||||||
export class AssetModule {}
|
export class AssetModule {}
|
||||||
|
@ -1,17 +1,15 @@
|
|||||||
import { IAssetRepository } from './asset-repository';
|
import { IAssetRepository } from './asset-repository';
|
||||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
|
||||||
import { AssetService } from './asset.service';
|
import { AssetService } from './asset.service';
|
||||||
import { Repository } from 'typeorm';
|
import { QueryFailedError, Repository } from 'typeorm';
|
||||||
import { AssetEntity, AssetType } from '@app/infra';
|
import { AssetEntity, AssetType } from '@app/infra';
|
||||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||||
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-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';
|
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
|
||||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||||
import { DownloadService } from '../../modules/download/download.service';
|
import { DownloadService } from '../../modules/download/download.service';
|
||||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
|
||||||
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
|
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
|
||||||
import { StorageService } from '@app/storage';
|
import { StorageService } from '@app/storage';
|
||||||
import { ICryptoRepository, IJobRepository, ISharedLinkRepository } from '@app/domain';
|
import { ICryptoRepository, IJobRepository, ISharedLinkRepository, JobName } from '@app/domain';
|
||||||
import {
|
import {
|
||||||
authStub,
|
authStub,
|
||||||
newCryptoRepositoryMock,
|
newCryptoRepositoryMock,
|
||||||
@ -23,105 +21,102 @@ import {
|
|||||||
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
||||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
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', () => {
|
describe('AssetService', () => {
|
||||||
let sui: AssetService;
|
let sut: AssetService;
|
||||||
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
|
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
|
||||||
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
||||||
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||||
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
|
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
|
||||||
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
|
let storageServiceMock: jest.Mocked<StorageService>;
|
||||||
let storageSeriveMock: jest.Mocked<StorageService>;
|
|
||||||
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
|
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
|
||||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||||
let jobMock: jest.Mocked<IJobRepository>;
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
const authUser: AuthUserDto = Object.freeze({
|
|
||||||
id: 'user_id_1',
|
|
||||||
email: 'auth@test.com',
|
|
||||||
isAdmin: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const _getCreateAssetDto = (): CreateAssetDto => {
|
beforeEach(() => {
|
||||||
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(() => {
|
|
||||||
assetRepositoryMock = {
|
assetRepositoryMock = {
|
||||||
|
get: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
|
remove: jest.fn(),
|
||||||
|
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
getAll: jest.fn(),
|
getAll: jest.fn(),
|
||||||
getAllVideos: jest.fn(),
|
getAllVideos: jest.fn(),
|
||||||
@ -151,18 +146,21 @@ describe('AssetService', () => {
|
|||||||
downloadArchive: jest.fn(),
|
downloadArchive: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
|
storageServiceMock = {
|
||||||
|
moveAsset: jest.fn(),
|
||||||
|
removeEmptyDirectories: jest.fn(),
|
||||||
|
} as unknown as jest.Mocked<StorageService>;
|
||||||
|
|
||||||
|
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
cryptoMock = newCryptoRepositoryMock();
|
cryptoMock = newCryptoRepositoryMock();
|
||||||
|
|
||||||
sui = new AssetService(
|
sut = new AssetService(
|
||||||
assetRepositoryMock,
|
assetRepositoryMock,
|
||||||
albumRepositoryMock,
|
albumRepositoryMock,
|
||||||
a,
|
a,
|
||||||
backgroundTaskServiceMock,
|
|
||||||
downloadServiceMock as DownloadService,
|
downloadServiceMock as DownloadService,
|
||||||
storageSeriveMock,
|
storageServiceMock,
|
||||||
sharedLinkRepositoryMock,
|
sharedLinkRepositoryMock,
|
||||||
jobMock,
|
jobMock,
|
||||||
cryptoMock,
|
cryptoMock,
|
||||||
@ -178,7 +176,7 @@ describe('AssetService', () => {
|
|||||||
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
|
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
|
||||||
sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid);
|
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.getById).toHaveBeenCalledWith(asset1.id);
|
||||||
expect(assetRepositoryMock.countByIdAndUser).toHaveBeenCalledWith(asset1.id, authStub.user1.id);
|
expect(assetRepositoryMock.countByIdAndUser).toHaveBeenCalledWith(asset1.id, authStub.user1.id);
|
||||||
@ -196,7 +194,7 @@ describe('AssetService', () => {
|
|||||||
sharedLinkRepositoryMock.get.mockResolvedValue(null);
|
sharedLinkRepositoryMock.get.mockResolvedValue(null);
|
||||||
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
|
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(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
|
||||||
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
||||||
@ -215,7 +213,7 @@ describe('AssetService', () => {
|
|||||||
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
|
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
|
||||||
sharedLinkRepositoryMock.save.mockResolvedValue(sharedLinkStub.valid);
|
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(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
|
||||||
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
||||||
@ -223,27 +221,94 @@ describe('AssetService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Currently failing due to calculate checksum from a file
|
describe('uploadFile', () => {
|
||||||
it('create an asset', async () => {
|
it('should handle a file upload', async () => {
|
||||||
const assetEntity = _getAsset_1();
|
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';
|
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
|
||||||
const mimeType = 'image/jpeg';
|
});
|
||||||
const createAssetDto = _getCreateAssetDto();
|
|
||||||
const result = await sui.createUserAsset(
|
|
||||||
authUser,
|
|
||||||
createAssetDto,
|
|
||||||
originalPath,
|
|
||||||
mimeType,
|
|
||||||
Buffer.from('0x5041E6328F7DF8AFF650BEDAED9251897D9A6241', 'hex'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.userId).toEqual(authUser.id);
|
it('should handle a duplicate', async () => {
|
||||||
expect(result.resizePath).toEqual('');
|
const file = {
|
||||||
expect(result.webpPath).toEqual('');
|
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 () => {
|
it('get assets by device id', async () => {
|
||||||
@ -254,7 +319,7 @@ describe('AssetService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const deviceId = 'device_id_1';
|
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.length).toEqual(2);
|
||||||
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
|
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
|
||||||
@ -267,7 +332,7 @@ describe('AssetService', () => {
|
|||||||
Promise.resolve<AssetCountByTimeBucket[]>(assetCountByTimeBucket),
|
Promise.resolve<AssetCountByTimeBucket[]>(assetCountByTimeBucket),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await sui.getAssetCountByTimeBucket(authUser, {
|
const result = await sut.getAssetCountByTimeBucket(authStub.user1, {
|
||||||
timeGroup: TimeGroupEnum.Month,
|
timeGroup: TimeGroupEnum.Month,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -282,18 +347,70 @@ describe('AssetService', () => {
|
|||||||
Promise.resolve<AssetCountByUserIdResponseDto>(assetCount),
|
Promise.resolve<AssetCountByUserIdResponseDto>(assetCount),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await sui.getAssetCountByUserId(authUser);
|
const result = await sut.getAssetCountByUserId(authStub.user1);
|
||||||
|
|
||||||
expect(result).toEqual(assetCount);
|
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', () => {
|
describe('checkDownloadAccess', () => {
|
||||||
it('should validate download access', async () => {
|
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 () => {
|
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 fs from 'fs/promises';
|
||||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||||
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '@app/domain';
|
import { AssetResponseDto, JobName, mapAsset, mapAssetWithoutExif } from '@app/domain';
|
||||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
|
||||||
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
|
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
|
||||||
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
|
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
|
||||||
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.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 { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
||||||
import { GetAssetByTimeBucketDto } from './dto/get-asset-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 { 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 { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
|
||||||
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
|
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
|
||||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||||
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
||||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
import { ICryptoRepository, IJobRepository } from '@app/domain';
|
||||||
import { ICryptoRepository, IJobRepository, JobName } from '@app/domain';
|
|
||||||
import { DownloadService } from '../../modules/download/download.service';
|
import { DownloadService } from '../../modules/download/download.service';
|
||||||
import { DownloadDto } from './dto/download-library.dto';
|
import { DownloadDto } from './dto/download-library.dto';
|
||||||
import { IAlbumRepository } from '../album/album-repository';
|
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 { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
|
||||||
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
|
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
|
||||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||||
import { ImmichFile } from '../../config/asset-upload.config';
|
|
||||||
|
|
||||||
const fileInfo = promisify(stat);
|
const fileInfo = promisify(stat);
|
||||||
|
|
||||||
@ -63,142 +61,69 @@ const fileInfo = promisify(stat);
|
|||||||
export class AssetService {
|
export class AssetService {
|
||||||
readonly logger = new Logger(AssetService.name);
|
readonly logger = new Logger(AssetService.name);
|
||||||
private shareCore: ShareCore;
|
private shareCore: ShareCore;
|
||||||
|
private assetCore: AssetCore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
|
||||||
@Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
|
@Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
|
||||||
@InjectRepository(AssetEntity)
|
@InjectRepository(AssetEntity)
|
||||||
private assetRepository: Repository<AssetEntity>,
|
private assetRepository: Repository<AssetEntity>,
|
||||||
private backgroundTaskService: BackgroundTaskService,
|
|
||||||
private downloadService: DownloadService,
|
private downloadService: DownloadService,
|
||||||
private storageService: StorageService,
|
storageService: StorageService,
|
||||||
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
|
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||||
) {
|
) {
|
||||||
|
this.assetCore = new AssetCore(_assetRepository, jobRepository, storageService);
|
||||||
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
|
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handleUploadedAsset(
|
public async uploadFile(
|
||||||
authUser: AuthUserDto,
|
authUser: AuthUserDto,
|
||||||
createAssetDto: CreateAssetDto,
|
dto: CreateAssetDto,
|
||||||
res: Res,
|
file: UploadFile,
|
||||||
originalAssetData: ImmichFile,
|
livePhotoFile?: UploadFile,
|
||||||
livePhotoAssetData?: ImmichFile,
|
): Promise<AssetFileUploadResponseDto> {
|
||||||
) {
|
if (livePhotoFile) {
|
||||||
const checksum = originalAssetData.checksum;
|
livePhotoFile.originalName = file.originalName;
|
||||||
const isLivePhoto = livePhotoAssetData !== undefined;
|
}
|
||||||
let livePhotoAssetEntity: AssetEntity | undefined;
|
|
||||||
|
let livePhotoAsset: AssetEntity | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isLivePhoto) {
|
if (livePhotoFile) {
|
||||||
const livePhotoChecksum = livePhotoAssetData.checksum;
|
const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false };
|
||||||
livePhotoAssetEntity = await this.createUserAsset(
|
livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
|
||||||
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 } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetEntity = await this.createUserAsset(
|
const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id);
|
||||||
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);
|
|
||||||
|
|
||||||
|
return { id: asset.id, duplicate: false };
|
||||||
|
} catch (error: any) {
|
||||||
|
// clean up files
|
||||||
await this.jobRepository.add({
|
await this.jobRepository.add({
|
||||||
name: JobName.ASSET_UPLOADED,
|
name: JobName.DELETE_FILE_ON_DISK,
|
||||||
data: { asset: movedAsset, fileName: originalAssetData.originalname },
|
data: {
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
originalPath: file.originalPath,
|
||||||
|
resizePath: livePhotoFile?.originalPath || null,
|
||||||
|
} as AssetEntity,
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return new AssetFileUploadResponseDto(movedAsset.id);
|
// handle duplicates with a success response
|
||||||
} catch (err) {
|
if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
|
||||||
await this.backgroundTaskService.deleteFileOnDisk([
|
const duplicate = await this.getAssetByChecksum(authUser.id, file.checksum);
|
||||||
{
|
return { id: duplicate.id, duplicate: true };
|
||||||
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,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
|
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||||
const existedAsset = await this.getAssetByChecksum(authUser.id, checksum);
|
throw new BadRequestException(`Error uploading file`, `${error}`);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
|
||||||
return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
|
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 result: DeleteAssetResponseDto[] = [];
|
||||||
|
|
||||||
const target = assetIds.ids;
|
const ids = dto.ids.slice();
|
||||||
for (const assetId of target) {
|
for (const id of ids) {
|
||||||
const res = await this.assetRepository.delete({
|
const asset = await this._assetRepository.get(id);
|
||||||
id: assetId,
|
if (!asset) {
|
||||||
});
|
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
|
||||||
|
continue;
|
||||||
if (res.affected) {
|
|
||||||
result.push({
|
|
||||||
id: assetId,
|
|
||||||
status: DeleteAssetStatusEnum.SUCCESS,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
result.push({
|
|
||||||
id: assetId,
|
|
||||||
status: DeleteAssetStatusEnum.FAILED,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return result;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
|
||||||
import { AssetType } from '@app/infra';
|
import { AssetType } from '@app/infra';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
|
||||||
|
import { ImmichFile } from '../../../config/asset-upload.config';
|
||||||
|
|
||||||
export class CreateAssetDto {
|
export class CreateAssetDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ -22,9 +23,29 @@ export class CreateAssetDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
isFavorite!: boolean;
|
isFavorite!: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isVisible?: boolean;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
fileExtension!: string;
|
fileExtension!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
duration?: string;
|
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 {
|
export class AssetFileUploadResponseDto {
|
||||||
constructor(id: string) {
|
id!: string;
|
||||||
this.id = id;
|
duplicate!: boolean;
|
||||||
}
|
|
||||||
|
|
||||||
id: string;
|
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
|
|||||||
async handleConnection(client: Socket) {
|
async handleConnection(client: Socket) {
|
||||||
try {
|
try {
|
||||||
this.logger.log(`New websocket connection: ${client.id}`);
|
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) {
|
if (user) {
|
||||||
client.join(user.id);
|
client.join(user.id);
|
||||||
} else {
|
} 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 { immichAppConfig } from '@app/common/config';
|
||||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
import { AssetModule } from './api-v1/asset/asset.module';
|
import { AssetModule } from './api-v1/asset/asset.module';
|
||||||
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
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 { CommunicationModule } from './api-v1/communication/communication.module';
|
||||||
import { AlbumModule } from './api-v1/album/album.module';
|
import { AlbumModule } from './api-v1/album/album.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
@ -17,14 +15,14 @@ import { InfraModule } from '@app/infra';
|
|||||||
import {
|
import {
|
||||||
APIKeyController,
|
APIKeyController,
|
||||||
AuthController,
|
AuthController,
|
||||||
|
DeviceInfoController,
|
||||||
OAuthController,
|
OAuthController,
|
||||||
ShareController,
|
ShareController,
|
||||||
SystemConfigController,
|
SystemConfigController,
|
||||||
UserController,
|
UserController,
|
||||||
} from './controllers';
|
} from './controllers';
|
||||||
import { PublicShareStrategy } from './modules/immich-auth/strategies/public-share.strategy';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { APIKeyStrategy } from './modules/immich-auth/strategies/api-key.strategy';
|
import { AuthGuard } from './middlewares/auth.guard';
|
||||||
import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.strategy';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -36,12 +34,8 @@ import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.str
|
|||||||
|
|
||||||
AssetModule,
|
AssetModule,
|
||||||
|
|
||||||
DeviceInfoModule,
|
|
||||||
|
|
||||||
ServerInfoModule,
|
ServerInfoModule,
|
||||||
|
|
||||||
BackgroundTaskModule,
|
|
||||||
|
|
||||||
CommunicationModule,
|
CommunicationModule,
|
||||||
|
|
||||||
AlbumModule,
|
AlbumModule,
|
||||||
@ -59,12 +53,13 @@ import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.str
|
|||||||
AppController,
|
AppController,
|
||||||
APIKeyController,
|
APIKeyController,
|
||||||
AuthController,
|
AuthController,
|
||||||
|
DeviceInfoController,
|
||||||
OAuthController,
|
OAuthController,
|
||||||
ShareController,
|
ShareController,
|
||||||
SystemConfigController,
|
SystemConfigController,
|
||||||
UserController,
|
UserController,
|
||||||
],
|
],
|
||||||
providers: [UserAuthStrategy, APIKeyStrategy, PublicShareStrategy],
|
providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
// TODO: check if consumer is needed or remove
|
// TODO: check if consumer is needed or remove
|
||||||
|
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 './api-key.controller';
|
||||||
export * from './auth.controller';
|
export * from './auth.controller';
|
||||||
|
export * from './device-info.controller';
|
||||||
export * from './oauth.controller';
|
export * from './oauth.controller';
|
||||||
export * from './share.controller';
|
export * from './share.controller';
|
||||||
export * from './system-config.controller';
|
export * from './system-config.controller';
|
||||||
|
@ -1,25 +1,28 @@
|
|||||||
import { UseGuards } from '@nestjs/common';
|
import { applyDecorators, SetMetadata } 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';
|
|
||||||
|
|
||||||
interface AuthenticatedOptions {
|
interface AuthenticatedOptions {
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum Metadata {
|
||||||
|
AUTH_ROUTE = 'auth_route',
|
||||||
|
ADMIN_ROUTE = 'admin_route',
|
||||||
|
SHARED_ROUTE = 'shared_route',
|
||||||
|
}
|
||||||
|
|
||||||
export const Authenticated = (options?: AuthenticatedOptions) => {
|
export const Authenticated = (options?: AuthenticatedOptions) => {
|
||||||
const guards: Parameters<typeof UseGuards> = [AuthGuard];
|
const decorators = [SetMetadata(Metadata.AUTH_ROUTE, true)];
|
||||||
|
|
||||||
options = options || {};
|
options = options || {};
|
||||||
|
|
||||||
if (options.admin) {
|
if (options.admin) {
|
||||||
guards.push(AdminRolesGuard);
|
decorators.push(SetMetadata(Metadata.ADMIN_ROUTE, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.isShared) {
|
if (options.isShared) {
|
||||||
guards.push(RouteNotSharedGuard);
|
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
@ -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 redisPort = parseInt(process.env.REDIS_PORT || '6379');
|
||||||
const redisDb = parseInt(process.env.REDIS_DBINDEX || '0');
|
const redisDb = parseInt(process.env.REDIS_DBINDEX || '0');
|
||||||
const redisPassword = process.env.REDIS_PASSWORD || undefined;
|
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 {
|
export class RedisIoAdapter extends IoAdapter {
|
||||||
private adapterConstructor: any;
|
private adapterConstructor: any;
|
||||||
|
|
||||||
async connectToRedis(): Promise<void> {
|
async connectToRedis(): Promise<void> {
|
||||||
const pubClient = createClient({
|
const pubClient = createClient({
|
||||||
url: `redis://${redisHost}:${redisPort}/${redisDb}`,
|
|
||||||
password: redisPassword,
|
password: redisPassword,
|
||||||
|
database: redisDb,
|
||||||
|
socket: {
|
||||||
|
host: redisHost,
|
||||||
|
port: redisPort,
|
||||||
|
path: redisSocket,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const subClient = pubClient.duplicate();
|
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 | null> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
validate(key: string): Promise<AuthUserDto | null> {
|
|
||||||
return this.shareService.validate(key);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
import { AuthService, AuthUserDto } from '@app/domain';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import { Strategy } from 'passport-custom';
|
|
||||||
|
|
||||||
export const AUTH_COOKIE_STRATEGY = 'auth-cookie';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class UserAuthStrategy extends PassportStrategy(Strategy, AUTH_COOKIE_STRATEGY) {
|
|
||||||
constructor(private authService: AuthService) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
validate(request: Request): Promise<AuthUserDto | null> {
|
|
||||||
return this.authService.validate(request.headers);
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,11 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing';
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { clearDb, getAuthUser, authCustom } from './test-utils';
|
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 { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
|
||||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
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 { DataSource } from 'typeorm';
|
||||||
import { AppModule } from '../src/app.module';
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
@ -20,9 +18,7 @@ describe('Album', () => {
|
|||||||
|
|
||||||
describe('without auth', () => {
|
describe('without auth', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
database = app.get(DataSource);
|
database = app.get(DataSource);
|
||||||
@ -46,9 +42,7 @@ describe('Album', () => {
|
|||||||
let authService: AuthService;
|
let authService: AuthService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const builder = Test.createTestingModule({
|
const builder = Test.createTestingModule({ imports: [AppModule] });
|
||||||
imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule],
|
|
||||||
});
|
|
||||||
authUser = getAuthUser(); // set default auth user
|
authUser = getAuthUser(); // set default auth user
|
||||||
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
|
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ import { CanActivate, ExecutionContext } from '@nestjs/common';
|
|||||||
import { TestingModuleBuilder } from '@nestjs/testing';
|
import { TestingModuleBuilder } from '@nestjs/testing';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
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;
|
type CustomAuthCallback = () => AuthUserDto;
|
||||||
|
|
||||||
@ -34,5 +34,5 @@ export function authCustom(builder: TestingModuleBuilder, callback: CustomAuthCa
|
|||||||
return true;
|
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 { INestApplication } from '@nestjs/common';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { clearDb, authCustom } from './test-utils';
|
import { clearDb, authCustom } from './test-utils';
|
||||||
import { InfraModule } from '@app/infra';
|
import { CreateUserDto, UserService, AuthUserDto } from '@app/domain';
|
||||||
import { DomainModule, CreateUserDto, UserService, AuthUserDto } from '@app/domain';
|
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { UserController } from '../src/controllers';
|
|
||||||
import { AuthService } from '@app/domain';
|
import { AuthService } from '@app/domain';
|
||||||
import { AppModule } from '../src/app.module';
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
@ -24,10 +22,7 @@ describe('User', () => {
|
|||||||
|
|
||||||
describe('without auth', () => {
|
describe('without auth', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
|
|
||||||
controllers: [UserController],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
database = app.get(DataSource);
|
database = app.get(DataSource);
|
||||||
@ -50,10 +45,7 @@ describe('User', () => {
|
|||||||
let authUser: AuthUserDto;
|
let authUser: AuthUserDto;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const builder = Test.createTestingModule({
|
const builder = Test.createTestingModule({ imports: [AppModule] });
|
||||||
imports: [DomainModule.register({ imports: [InfraModule] })],
|
|
||||||
controllers: [UserController],
|
|
||||||
});
|
|
||||||
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
|
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
|
@ -14,6 +14,7 @@ import { StorageMigrationProcessor } from './processors/storage-migration.proces
|
|||||||
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
|
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
|
||||||
import { UserDeletionProcessor } from './processors/user-deletion.processor';
|
import { UserDeletionProcessor } from './processors/user-deletion.processor';
|
||||||
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
|
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
|
||||||
|
import { BackgroundTaskProcessor } from './processors/background-task.processor';
|
||||||
import { DomainModule } from '@app/domain';
|
import { DomainModule } from '@app/domain';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -37,6 +38,7 @@ import { DomainModule } from '@app/domain';
|
|||||||
MachineLearningProcessor,
|
MachineLearningProcessor,
|
||||||
UserDeletionProcessor,
|
UserDeletionProcessor,
|
||||||
StorageMigrationProcessor,
|
StorageMigrationProcessor,
|
||||||
|
BackgroundTaskProcessor,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class MicroservicesModule {}
|
export class MicroservicesModule {}
|
||||||
|
@ -2,7 +2,7 @@ import { assetUtils } from '@app/common/utils';
|
|||||||
import { Process, Processor } from '@nestjs/bull';
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
import { Job } from 'bull';
|
import { Job } from 'bull';
|
||||||
import { JobName, QueueName } from '@app/domain';
|
import { JobName, QueueName } from '@app/domain';
|
||||||
import { AssetEntity } from '@app/infra';
|
import { AssetEntity } from '@app/infra/db/entities';
|
||||||
|
|
||||||
@Processor(QueueName.BACKGROUND_TASK)
|
@Processor(QueueName.BACKGROUND_TASK)
|
||||||
export class BackgroundTaskProcessor {
|
export class BackgroundTaskProcessor {
|
@ -235,6 +235,10 @@ export class MetadataExtractionProcessor {
|
|||||||
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
||||||
const { asset, fileName } = job.data;
|
const { asset, fileName } = job.data;
|
||||||
|
|
||||||
|
if (!asset.isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await new Promise<ffmpeg.FfprobeData>((resolve, reject) =>
|
const data = await new Promise<ffmpeg.FfprobeData>((resolve, reject) =>
|
||||||
ffmpeg.ffprobe(asset.originalPath, (err, data) => {
|
ffmpeg.ffprobe(asset.originalPath, (err, data) => {
|
||||||
|