1
0
mirror of https://github.com/immich-app/immich.git synced 2025-02-07 18:50:19 +02:00

Merge branch 'immich-app:main' into feat/samsung-raw-and-fujifilm-raf

This commit is contained in:
Skyler Mäntysaari 2023-02-02 11:11:04 +02:00 committed by GitHub
commit 591d3b7afe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
149 changed files with 4833 additions and 2011 deletions

19
.editorconfig Normal file
View 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

View File

@ -2,54 +2,52 @@ name: Build Mobile
on:
workflow_dispatch:
workflow_call:
pull_request:
push:
branches: [main]
jobs:
build-sign-android:
name: Build and sign Android
runs-on: ubuntu-latest
runs-on: macos-12
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
distribution: 'zulu'
distribution: "zulu"
java-version: "12.x"
cache: 'gradle'
cache: "gradle"
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.3.10'
channel: "stable"
flutter-version: "3.3.10"
cache: true
- name: Create the Keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_SIGN_KEY_CONTENT }}
run: |
# import keystore from secrets
echo $KEYSTORE_BASE64 | base64 -d > $RUNNER_TEMP/my_production.keystore
KEY_JKS: ${{ secrets.KEY_JKS }}
working-directory: ./mobile
run: echo $KEY_JKS | base64 -d > android/key.jks
- name: Restore packages
- name: Get Packages
working-directory: ./mobile
run: flutter pub get
- name: Build Android App Bundle
working-directory: ./mobile
env:
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
run: flutter build apk --release
- name: 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
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
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

View File

@ -34,7 +34,7 @@ jobs:
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.2.1
uses: docker/setup-buildx-action@v2.4.0
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761
@ -89,7 +89,7 @@ jobs:
fi
- name: Build and push image
uses: docker/build-push-action@v3.3.0
uses: docker/build-push-action@v4.0.0
with:
context: ${{ matrix.context }}
platforms: linux/arm/v7,linux/amd64,linux/arm64

View File

@ -4,23 +4,28 @@ on:
workflow_dispatch:
inputs:
serverBump:
description: 'Bump server version'
description: "Bump server version"
required: true
default: 'false'
default: "false"
type: choice
options:
- false
- minor
- patch
- "false"
- minor
- patch
mobileBump:
description: 'Bump mobile build number'
description: "Bump mobile build number"
required: false
type: boolean
jobs:
build_mobile:
uses: ./.github/workflows/build-mobile.yml
secrets: inherit
tag_release:
runs-on: ubuntu-latest
needs: build_mobile
steps:
- name: Checkout
uses: actions/checkout@v3
@ -29,7 +34,7 @@ jobs:
- name: Bump version
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
- name: Commit and tag
uses: EndBug/add-and-commit@v9
with:
@ -38,7 +43,12 @@ jobs:
message: "Version ${{ env.IMMICH_VERSION }}"
tag: ${{ env.IMMICH_VERSION }}
push: true
- name: Download APK
uses: actions/download-artifact@v3
with:
name: release-apk-signed
- name: Create draft release
uses: softprops/action-gh-release@v1
with:
@ -49,3 +59,4 @@ jobs:
files: |
docker/docker-compose.yml
docker/example.env
*.apk

View File

@ -44,7 +44,7 @@ jobs:
name: Run mobile unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
@ -58,7 +58,7 @@ jobs:
name: Run mobile end-to-end integration tests
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
distribution: 'adopt'

3
.gitignore vendored
View File

@ -8,4 +8,5 @@ uploads
coverage
mobile/gradle.properties
mobile/openapi/pubspec.lock
mobile/openapi/pubspec.lock
mobile/*.jks

View File

@ -6,7 +6,7 @@
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m>
#
# examples:
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
# ./scripts/pump-version.sh -s minor -m # 1.0.0+50 => 1.1.0+51
# ./scripts/pump-version.sh -m # 1.0.0+50 => 1.0.0+51
#
@ -16,10 +16,12 @@ MOBILE_PUMP="false"
while getopts 's:m:' flag; do
case "${flag}" in
s) SERVER_PUMP=${OPTARG} ;;
m) MOBILE_PUMP=${OPTARG} ;;
*) echo "Invalid args"
exit 1 ;;
s) SERVER_PUMP=${OPTARG} ;;
m) MOBILE_PUMP=${OPTARG} ;;
*)
echo "Invalid args"
exit 1
;;
esac
done
@ -30,8 +32,11 @@ PATCH=$(echo $CURRENT_SERVER | cut -d '.' -f3)
if [[ $SERVER_PUMP == "major" ]]; then
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif [[ $SERVER_PUMP == "minor" ]]; then
MINOR=$((MINOR + 1))
PATCH=0
elif [[ $SERVER_PUMP == "patch" ]]; then
PATCH=$((PATCH + 1))
elif [[ $SERVER_PUMP == "false" ]]; then
@ -54,8 +59,6 @@ else
exit 1
fi
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
@ -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
fi
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
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
fi
echo "IMMICH_VERSION=v$NEXT_SERVER" >> $GITHUB_ENV
echo "IMMICH_VERSION=v$NEXT_SERVER" >>$GITHUB_ENV

View File

@ -57,21 +57,22 @@ android {
versionName flutterVersionName
}
// signingConfigs {
// release {
// keyAlias keystoreProperties['keyAlias']
// keyPassword keystoreProperties['keyPassword']
// storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
// storePassword keystoreProperties['storePassword']
// }
// }
signingConfigs {
release {
def keyAliasVal = System.getenv("ALIAS")
def keyPasswordVal = System.getenv("ANDROID_KEY_PASSWORD")
def storePasswordVal = System.getenv("ANDROID_STORE_PASSWORD")
keyAlias keyAliasVal ? keyAliasVal : keystoreProperties['keyAlias']
keyPassword keyPasswordVal ? keyPasswordVal : keystoreProperties['keyPassword']
storeFile file("../key.jks") ? file("../key.jks") : file(keystoreProperties['storeFile'])
storePassword storePasswordVal ? storePasswordVal : keystoreProperties['storePassword']
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
// signingConfig signingConfigs.release
signingConfig null
signingConfig signingConfigs.release
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

View File

@ -1,6 +1,12 @@
<?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">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

View File

@ -1,6 +1,12 @@
<?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">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -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>

View File

@ -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>

View File

@ -5,9 +5,6 @@
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<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>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 66,
"android.injected.version.name" => "1.43.1",
"android.injected.version.code" => 67,
"android.injected.version.name" => "1.44.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View File

@ -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'

View File

@ -1,23 +1,23 @@
{
"images" : [
{
"filename" : "LaunchImage.png",
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 68 B

View File

@ -16,19 +16,13 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<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>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<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"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
@ -39,6 +33,5 @@
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>

View File

@ -1,97 +1,105 @@
<?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">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Immich</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.42.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>79</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSLocationAlwaysUsageDescription</key>
<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>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<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>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>io.flutter.embedded_views_preview</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>CFBundleLocalizations</key>
<array>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>en</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ru</string>
<string>sk</string>
<string>zh</string>
</array>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Immich</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.43.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>82</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
</dict>
<key>NSLocationAlwaysUsageDescription</key>
<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>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<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>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
<false />
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>CFBundleLocalizations</key>
<array>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>en</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ru</string>
<string>sk</string>
<string>zh</string>
</array>
</dict>
</plist>

View File

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

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.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) {
return GestureDetector(
onTap: () {
titleFocusNode.unfocus();
},
child: DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
heightScrollThumb: 48.0,
child: CustomScrollView(
return WillPopScope(
onWillPop: onWillPop,
child: GestureDetector(
onTap: () {
titleFocusNode.unfocus();
},
child: DraggableScrollbar.semicircle(
backgroundColor: Theme.of(context).hintColor,
controller: scrollController,
slivers: [
buildHeader(albumInfo),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: buildControlButton(albumInfo),
heightScrollThumb: 48.0,
child: CustomScrollView(
controller: scrollController,
slivers: [
buildHeader(albumInfo),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: buildControlButton(albumInfo),
),
),
),
),
buildImageGrid(albumInfo)
],
buildImageGrid(albumInfo)
],
),
),
),
);

View File

@ -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();
}
}

View File

@ -1,4 +1,7 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -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/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:openapi/api.dart' as api;
// ignore: must_be_immutable
class GalleryViewerPage extends HookConsumerWidget {
@ -40,7 +50,8 @@ class GalleryViewerPage extends HookConsumerWidget {
final isZoomed = useState<bool>(false);
final indexOfAsset = useState(assetList.indexOf(asset));
final isPlayingMotionVideo = useState(false);
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
late Offset localPosition;
final authToken = 'Bearer ${box.get(accessTokenKey)}';
PageController controller =
PageController(initialPage: assetList.indexOf(asset));
@ -57,7 +68,7 @@ class GalleryViewerPage extends HookConsumerWidget {
[],
);
getAssetExif() async {
void getAssetExif() async {
if (assetList[indexOfAsset.value].isRemote) {
assetDetail = await ref
.watch(assetServiceProvider)
@ -68,27 +79,96 @@ class GalleryViewerPage extends HookConsumerWidget {
}
}
void showInfo() {
showModalBottomSheet(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
/// Thumbnail image of a remote asset. Required asset.remote != null
ImageProvider remoteThumbnailImageProvider(Asset asset, api.ThumbnailFormat type) {
return CachedNetworkImageProvider(
getThumbnailUrl(
asset.remote!,
type: type,
),
barrierColor: Colors.transparent,
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
},
cacheKey: getThumbnailCacheKey(
asset.remote!,
type: type,
),
headers: {"Authorization": authToken},
);
}
//make isZoomed listener call instead
void isZoomedMethod() {
if (isZoomedListener.value) {
isZoomed.value = true;
} else {
isZoomed.value = false;
/// Original (large) image of a remote asset. Required asset.remote != null
ImageProvider originalImageProvider(Asset asset) {
return CachedNetworkImageProvider(
getImageUrl(asset.remote!),
cacheKey: getImageCacheKey(asset.remote!),
headers: {"Authorization": authToken},
);
}
/// Thumbnail image of a local asset. Required asset.local != null
ImageProvider localThumbnailImageProvider(Asset asset) {
return AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250),
);
}
/// Original (large) image of a local asset. Required asset.local != null
ImageProvider localImageProvider(Asset asset) {
return AssetEntityImageProvider(asset.local!);
}
void precacheNextImage(int index) {
if (index < assetList.length && index > 0) {
final asset = assetList[index];
if (asset.isLocal) {
// Preload the local asset
precacheImage(localImageProvider(asset), context);
} else {
// Probably load WEBP either way
precacheImage(
remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.WEBP,
),
context,
);
if (isLoadPreview.value) {
// Precache the JPEG thumbnail
precacheImage(
remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.JPEG,
),
context,
);
}
if (isLoadOriginal.value) {
// Preload the original asset
precacheImage(
originalImageProvider(asset),
context,
);
}
}
}
}
void showInfo() {
if (assetList[indexOfAsset.value].isRemote) {
showModalBottomSheet(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15.0),
),
barrierColor: Colors.transparent,
backgroundColor: Colors.transparent,
isScrollControlled: true,
context: context,
builder: (context) {
return ExifBottomSheet(assetDetail: assetDetail!);
},
);
}
}
@ -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(
backgroundColor: Colors.black,
appBar: TopControlAppBar(
@ -150,61 +252,93 @@ class GalleryViewerPage extends HookConsumerWidget {
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]),
),
body: SafeArea(
child: PageView.builder(
controller: controller,
pageSnapping: true,
physics: isZoomed.value
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(),
child: PhotoViewGallery.builder(
scaleStateChangedCallback: (state) => isZoomed.value = state != PhotoViewScaleState.initial,
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const BouncingScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android
),
itemCount: assetList.length,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
// Precache image
if (indexOfAsset.value < value) {
// Moving forwards, so precache the next asset
precacheNextImage(value + 1);
} else {
// Moving backwards, so precache previous asset
precacheNextImage(value - 1);
}
indexOfAsset.value = value;
HapticFeedback.selectionClick();
},
itemBuilder: (context, index) {
getAssetExif();
loadingBuilder: isLoadPreview.value ? (context, event) {
final asset = assetList[indexOfAsset.value];
if (!asset.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.WEBP),
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.WEBP),
httpHeaders: { 'Authorization': authToken },
progressIndicatorBuilder: (_, __, ___) => const Center(child: ImmichLoadingIndicator(),),
fit: BoxFit.contain,
);
if (assetList[index].isImage) {
if (isPlayingMotionVideo.value) {
return VideoViewerPage(
asset: assetList[index],
isMotionVideo: true,
onVideoEnded: () {
isPlayingMotionVideo.value = false;
},
);
} else {
return ImageViewerPage(
authToken: 'Bearer ${box.get(accessTokenKey)}',
isZoomedFunction: isZoomedMethod,
isZoomedListener: isZoomedListener,
asset: assetList[index],
heroTag: assetList[index].id,
loadPreview: isLoadPreview.value,
loadOriginal: isLoadOriginal.value,
showExifSheet: showInfo,
);
}
return CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.JPEG),
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.JPEG),
httpHeaders: { 'Authorization': authToken },
fit: BoxFit.contain,
placeholder: (_, __) => webPThumbnail,
);
} else {
return GestureDetector(
onVerticalDragUpdate: (details) {
const int sensitivity = 15;
if (details.delta.dy > sensitivity) {
// swipe down
AutoRouter.of(context).pop();
} else if (details.delta.dy < -sensitivity) {
// swipe up
showInfo();
}
},
child: Hero(
tag: assetList[index].id,
child: VideoViewerPage(
asset: assetList[index],
isMotionVideo: false,
onVideoEnded: () {},
),
return Image(
image: localThumbnailImageProvider(asset),
fit: BoxFit.contain,
);
}
} : null,
builder: (context, index) {
getAssetExif();
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
// Show photo
final ImageProvider provider;
if (assetList[index].isLocal) {
provider = localImageProvider(assetList[index]);
} else {
if (isLoadOriginal.value) {
provider = originalImageProvider(assetList[index]);
} else {
provider = remoteThumbnailImageProvider(
assetList[index],
api.ThumbnailFormat.JPEG,
);
}
}
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) => localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
minScale: PhotoViewComputedScale.contained,
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) => localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
child: VideoViewerPage(
asset: assetList[index],
isMotionVideo: isPlayingMotionVideo.value,
onVideoEnded: () {
if (isPlayingMotionVideo.value) {
isPlayingMotionVideo.value = false;
}
},
),
);
}
@ -214,3 +348,4 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
}

View File

@ -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(),
),
],
);
}
}

View File

@ -29,6 +29,7 @@ final backupServiceProvider = Provider(
);
class BackupService {
final httpClient = http.Client();
final ApiService _apiService;
BackupService(this._apiService);
@ -282,7 +283,8 @@ class BackupService {
),
);
var response = await req.send(cancellationToken: cancelToken);
var response =
await httpClient.send(req, cancellationToken: cancelToken);
if (response.statusCode == 200) {
// asset is a duplicate (already exists on the server)
@ -334,7 +336,6 @@ class BackupService {
Future<MultipartFile?> _getLivePhotoFile(AssetEntity entity) async {
var motionFilePath = await entity.getMediaUrl();
// var motionFilePath = '/var/mobile/Media/DCIM/103APPLE/IMG_3371.MOV'
if (motionFilePath != null) {
var validPath = motionFilePath.replaceAll('file://', '');

View File

@ -200,34 +200,46 @@ class HomePage extends HookConsumerWidget {
);
}
return SafeArea(
bottom: !multiselectEnabled.state,
top: true,
child: Stack(
children: [
ref.watch(assetProvider).renderList == null ||
ref.watch(assetProvider).allAssets.isEmpty
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: ref.watch(assetProvider).renderList!,
allAssets: ref.watch(assetProvider).allAssets,
assetsPerRow: appSettingService
.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
if (selectionEnabledHook.value)
ControlBottomAppBar(
onShare: onShareAssets,
onDelete: onDelete,
onAddToAlbum: onAddToAlbum,
albums: albums,
sharedAlbums: sharedAlbums,
onCreateNewAlbum: onCreateNewAlbum,
),
],
Future<bool> onWillPop() async {
if (multiselectEnabled.state) {
selectionEnabledHook.value = false;
return false;
}
return true;
}
return WillPopScope(
onWillPop: onWillPop,
child: SafeArea(
bottom: !multiselectEnabled.state,
top: true,
child: Stack(
children: [
ref.watch(assetProvider).renderList == null ||
ref.watch(assetProvider).allAssets.isEmpty
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: ref.watch(assetProvider).renderList!,
allAssets: ref.watch(assetProvider).allAssets,
assetsPerRow: appSettingService
.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
),
if (selectionEnabledHook.value)
ControlBottomAppBar(
onShare: onShareAssets,
onDelete: onDelete,
onAddToAlbum: onAddToAlbum,
albums: albums,
sharedAlbums: sharedAlbums,
onCreateNewAlbum: onCreateNewAlbum,
),
],
),
),
);
}

View File

@ -10,7 +10,6 @@ import 'package:immich_mobile/modules/album/views/select_additional_user_for_sha
import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
@ -52,7 +51,6 @@ part 'router.gr.dart';
transitionsBuilder: TransitionsBuilders.fadeIn,
),
AutoRoute(page: GalleryViewerPage, guards: [AuthGuard]),
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),

View File

@ -48,21 +48,6 @@ class _$AppRouter extends RootStackRouter {
child: GalleryViewerPage(
key: args.key, assetList: args.assetList, asset: args.asset));
},
ImageViewerRoute.name: (routeData) {
final args = routeData.argsAs<ImageViewerRouteArgs>();
return MaterialPageX<dynamic>(
routeData: routeData,
child: ImageViewerPage(
key: args.key,
heroTag: args.heroTag,
asset: args.asset,
authToken: args.authToken,
isZoomedFunction: args.isZoomedFunction,
isZoomedListener: args.isZoomedListener,
loadPreview: args.loadPreview,
loadOriginal: args.loadOriginal,
showExifSheet: args.showExifSheet));
},
VideoViewerRoute.name: (routeData) {
final args = routeData.argsAs<VideoViewerRouteArgs>();
return MaterialPageX<dynamic>(
@ -204,8 +189,6 @@ class _$AppRouter extends RootStackRouter {
]),
RouteConfig(GalleryViewerRoute.name,
path: '/gallery-viewer-page', guards: [authGuard]),
RouteConfig(ImageViewerRoute.name,
path: '/image-viewer-page', guards: [authGuard]),
RouteConfig(VideoViewerRoute.name,
path: '/video-viewer-page', guards: [authGuard]),
RouteConfig(BackupControllerRoute.name,
@ -299,71 +282,6 @@ class GalleryViewerRouteArgs {
}
}
/// generated route for
/// [ImageViewerPage]
class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
ImageViewerRoute(
{Key? key,
required String heroTag,
required Asset asset,
required String authToken,
required void Function() isZoomedFunction,
required ValueNotifier<bool> isZoomedListener,
required bool loadPreview,
required bool loadOriginal,
void Function()? showExifSheet})
: super(ImageViewerRoute.name,
path: '/image-viewer-page',
args: ImageViewerRouteArgs(
key: key,
heroTag: heroTag,
asset: asset,
authToken: authToken,
isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener,
loadPreview: loadPreview,
loadOriginal: loadOriginal,
showExifSheet: showExifSheet));
static const String name = 'ImageViewerRoute';
}
class ImageViewerRouteArgs {
const ImageViewerRouteArgs(
{this.key,
required this.heroTag,
required this.asset,
required this.authToken,
required this.isZoomedFunction,
required this.isZoomedListener,
required this.loadPreview,
required this.loadOriginal,
this.showExifSheet});
final Key? key;
final String heroTag;
final Asset asset;
final String authToken;
final void Function() isZoomedFunction;
final ValueNotifier<bool> isZoomedListener;
final bool loadPreview;
final bool loadOriginal;
final void Function()? showExifSheet;
@override
String toString() {
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal, showExifSheet: $showExifSheet}';
}
}
/// generated route for
/// [VideoViewerPage]
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {

View File

@ -22,7 +22,7 @@ class ShareService {
}
Future<void> shareAssets(List<Asset> assets) async {
final downloadedFilePaths = assets.map((asset) async {
final downloadedXFiles = assets.map<Future<XFile>>((asset) async {
if (asset.isRemote) {
final tempDir = await getTemporaryDirectory();
final fileName = basename(asset.remote!.originalPath);
@ -33,16 +33,16 @@ class ShareService {
isWeb: false,
);
tempFile.writeAsBytesSync(res.bodyBytes);
return tempFile.path;
return XFile(tempFile.path);
} else {
File? f = await asset.local!.file;
return f!.path;
return XFile(f!.path);
}
});
// ignore: deprecated_member_use
Share.shareFiles(
await Future.wait(downloadedFilePaths),
Share.shareXFiles(
await Future.wait(downloadedXFiles),
sharePositionOrigin: Rect.zero,
);
}

View 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,
);

View 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;
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View 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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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),
),
);
}
}

View File

@ -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;
}

View 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,
);
}
}

View File

@ -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)';
}

View File

@ -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;
}

View 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;
}

View File

@ -57,12 +57,30 @@ class SplashScreenPage extends HookConsumerWidget {
[],
);
return const Scaffold(
return Scaffold(
body: Center(
child: Image(
image: AssetImage('assets/immich-logo-no-outline.png'),
width: 200,
filterQuality: FilterQuality.high,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
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,
),
),
),
],
),
),
);

View File

@ -8,7 +8,4 @@ create_app_icon:
flutter pub run flutter_launcher_icons:main
build_release_android:
flutter build appbundle
create_splash:
flutter pub run flutter_native_splash:create
flutter build appbundle

View File

@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.42.0
- API version: 1.43.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements

View File

@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
**duplicate** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -14,25 +14,31 @@ class AssetFileUploadResponseDto {
/// Returns a new [AssetFileUploadResponseDto] instance.
AssetFileUploadResponseDto({
required this.id,
required this.duplicate,
});
String id;
bool duplicate;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetFileUploadResponseDto &&
other.id == id;
other.id == id &&
other.duplicate == duplicate;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode);
(id.hashCode) +
(duplicate.hashCode);
@override
String toString() => 'AssetFileUploadResponseDto[id=$id]';
String toString() => 'AssetFileUploadResponseDto[id=$id, duplicate=$duplicate]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'id'] = this.id;
json[r'duplicate'] = this.duplicate;
return json;
}
@ -56,6 +62,7 @@ class AssetFileUploadResponseDto {
return AssetFileUploadResponseDto(
id: mapValueOfType<String>(json, r'id')!,
duplicate: mapValueOfType<bool>(json, r'duplicate')!,
);
}
return null;
@ -106,6 +113,7 @@ class AssetFileUploadResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'id',
'duplicate',
};
}

View File

@ -21,6 +21,11 @@ void main() {
// TODO
});
// bool duplicate
test('to test the property `duplicate`', () async {
// TODO
});
});

View File

@ -210,7 +210,7 @@ packages:
name: cross_file
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.3+1"
version: "0.3.3+2"
crypto:
dependency: transitive
description:
@ -239,6 +239,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.3"
easy_image_viewer:
dependency: "direct main"
description:
name: easy_image_viewer
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
easy_localization:
dependency: "direct main"
description:
@ -325,7 +332,7 @@ packages:
name: flutter_launcher_icons
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.3"
version: "0.9.2"
flutter_lints:
dependency: "direct dev"
description:
@ -345,13 +352,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: transitive
description:
@ -457,7 +457,7 @@ packages:
name: html
url: "https://pub.dartlang.org"
source: hosted
version: "0.15.1"
version: "0.15.0"
http:
dependency: "direct main"
description:
@ -480,12 +480,12 @@ packages:
source: hosted
version: "4.0.1"
image:
dependency: "direct overridden"
dependency: transitive
description:
name: image
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.12"
version: "3.2.0"
image_picker:
dependency: "direct main"
description:
@ -764,13 +764,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.5.0"
photo_view:
dependency: "direct main"
description:
name: photo_view
url: "https://pub.dartlang.org"
source: hosted
version: "0.14.0"
platform:
dependency: transitive
description:
@ -868,42 +861,14 @@ packages:
name: share_plus
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.10"
share_plus_linux:
dependency: transitive
description:
name: share_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
share_plus_macos:
dependency: transitive
description:
name: share_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
version: "6.3.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.3"
share_plus_web:
dependency: transitive
description:
name: share_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
share_plus_windows:
dependency: transitive
description:
name: share_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
version: "3.2.0"
shared_preferences:
dependency: transitive
description:
@ -1126,13 +1091,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: "direct main"
description:
@ -1174,14 +1132,14 @@ packages:
name: url_launcher_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
version: "2.1.1"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.11"
version: "2.0.14"
url_launcher_windows:
dependency: transitive
description:
@ -1279,7 +1237,7 @@ packages:
name: wakelock_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
version: "0.2.1"
watcher:
dependency: transitive
description:
@ -1307,7 +1265,7 @@ packages:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.7.0"
version: "3.1.3"
wkt_parser:
dependency: transitive
description:

View File

@ -23,7 +23,6 @@ dependencies:
video_player: ^2.2.18
chewie: ^1.3.5
badges: ^2.0.2
photo_view: ^0.14.0
socket_io_client: ^2.0.0-beta.4-nullsafety.0
flutter_map: ^0.14.0
flutter_udid: ^2.0.0
@ -32,7 +31,7 @@ dependencies:
http: 0.13.4
cancellation_token_http: ^1.1.0
easy_localization: ^3.0.1
share_plus: ^4.0.10
share_plus: ^6.3.0
flutter_displaymode: ^0.4.0
scrollable_positioned_list: ^0.3.4
path: ^1.8.1
@ -41,6 +40,7 @@ dependencies:
collection: ^1.16.0
http_parser: ^4.0.1
flutter_web_auth: ^0.5.0
easy_image_viewer: ^1.2.0
openapi:
path: openapi
@ -56,14 +56,10 @@ dev_dependencies:
hive_generator: ^1.1.2
build_runner: ^2.2.1
auto_route_generator: ^5.0.2
flutter_launcher_icons: ^0.9.2
flutter_native_splash: ^2.2.17
flutter_launcher_icons: "^0.9.2"
integration_test:
sdk: flutter
dependency_overrides:
image: ^4.0.12
flutter:
uses-material-design: true
assets:

View File

@ -101,7 +101,7 @@ export class AlbumService {
const album = await this._getAlbum({ authUser, albumId });
for (const sharedLink of album.sharedLinks) {
await this.shareCore.remove(sharedLink.id, authUser.id);
await this.shareCore.remove(authUser.id, sharedLink.id);
}
await this._albumRepository.delete(album);

View File

@ -1,10 +1,9 @@
import { SearchPropertiesDto } from './dto/search-properties.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetEntity, AssetType } from '@app/infra';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm/repository/Repository';
import { CreateAssetDto } from './dto/create-asset.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
@ -19,15 +18,10 @@ import { IsNull, Not } from 'typeorm';
import { AssetSearchDto } from './dto/asset-search.dto';
export interface IAssetRepository {
create(
createAssetDto: CreateAssetDto,
ownerId: string,
originalPath: string,
mimeType: string,
isVisible: boolean,
checksum?: Buffer,
livePhotoAssetEntity?: AssetEntity,
): Promise<AssetEntity>;
get(id: string): Promise<AssetEntity | null>;
create(asset: Omit<AssetEntity, 'id'>): Promise<AssetEntity>;
remove(asset: AssetEntity): Promise<void>;
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
getAll(): Promise<AssetEntity[]>;
getAllVideos(): Promise<AssetEntity[]>;
@ -282,44 +276,16 @@ export class AssetRepository implements IAssetRepository {
});
}
/**
* Create new asset information in database
* @param createAssetDto
* @param ownerId
* @param originalPath
* @param mimeType
* @returns Promise<AssetEntity>
*/
async create(
createAssetDto: CreateAssetDto,
ownerId: string,
originalPath: string,
mimeType: string,
isVisible: boolean,
checksum?: Buffer,
livePhotoAssetEntity?: AssetEntity,
): Promise<AssetEntity> {
const asset = new AssetEntity();
asset.deviceAssetId = createAssetDto.deviceAssetId;
asset.userId = ownerId;
asset.deviceId = createAssetDto.deviceId;
asset.type = !isVisible ? AssetType.VIDEO : createAssetDto.assetType || AssetType.OTHER; // If an asset is not visible, it is a LivePhotos video portion, therefore we can confidently assign the type as VIDEO here
asset.originalPath = originalPath;
asset.createdAt = createAssetDto.createdAt;
asset.modifiedAt = createAssetDto.modifiedAt;
asset.isFavorite = createAssetDto.isFavorite;
asset.mimeType = mimeType;
asset.duration = createAssetDto.duration || null;
asset.checksum = checksum || null;
asset.isVisible = isVisible;
asset.livePhotoVideoId = livePhotoAssetEntity ? livePhotoAssetEntity.id : null;
get(id: string): Promise<AssetEntity | null> {
return this.assetRepository.findOne({ where: { id } });
}
const createdAsset = await this.assetRepository.save(asset);
async create(asset: Omit<AssetEntity, 'id'>): Promise<AssetEntity> {
return this.assetRepository.save(asset);
}
if (!createdAsset) {
throw new BadRequestException('Asset not created');
}
return createdAsset;
async remove(asset: AssetEntity): Promise<void> {
await this.assetRepository.remove(asset);
}
/**

View File

@ -19,11 +19,9 @@ import {
import { Authenticated } from '../../decorators/authenticated.decorator';
import { AssetService } from './asset.service';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res } from 'express';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
@ -33,9 +31,9 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re
import { AssetResponseDto } from '@app/domain';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
import { CreateAssetDto } from './dto/create-asset.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
@ -55,12 +53,13 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { SharedLinkResponseDto } from '@app/domain';
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
@ApiBearerAuth()
@ApiTags('Asset')
@Controller('asset')
export class AssetController {
constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {}
constructor(private assetService: AssetService) {}
@Authenticated({ isShared: true })
@Post('upload')
@ -81,13 +80,22 @@ export class AssetController {
async uploadFile(
@GetAuthUser() authUser: AuthUserDto,
@UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] },
@Body(ValidationPipe) createAssetDto: CreateAssetDto,
@Body(ValidationPipe) dto: CreateAssetDto,
@Response({ passthrough: true }) res: Res,
): Promise<AssetFileUploadResponseDto> {
const originalAssetData = files.assetData[0];
const livePhotoAssetData = files.livePhotoData?.[0];
const file = mapToUploadFile(files.assetData[0]);
const _livePhotoFile = files.livePhotoData?.[0];
let livePhotoFile;
if (_livePhotoFile) {
livePhotoFile = mapToUploadFile(_livePhotoFile);
}
return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData);
const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile);
if (responseDto.duplicate) {
res.send(200);
}
return responseDto;
}
@Authenticated({ isShared: true })
@ -276,37 +284,10 @@ export class AssetController {
@Delete('/')
async deleteAsset(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) assetIds: DeleteAssetDto,
@Body(ValidationPipe) dto: DeleteAssetDto,
): Promise<DeleteAssetResponseDto[]> {
await this.assetService.checkAssetsAccess(authUser, assetIds.ids, true);
const deleteAssetList: AssetResponseDto[] = [];
for (const id of assetIds.ids) {
const assets = await this.assetService.getAssetById(authUser, id);
if (!assets) {
continue;
}
deleteAssetList.push(assets);
if (assets.livePhotoVideoId) {
const livePhotoVideo = await this.assetService.getAssetById(authUser, assets.livePhotoVideoId);
if (livePhotoVideo) {
deleteAssetList.push(livePhotoVideo);
assetIds.ids = [...assetIds.ids, livePhotoVideo.id];
}
}
}
const result = await this.assetService.deleteAssetById(assetIds);
result.forEach((res) => {
deleteAssetList.filter((a) => a.id == res.id && res.status == DeleteAssetStatusEnum.SUCCESS);
});
await this.backgroundTaskService.deleteFileOnDisk(deleteAssetList as any[]);
return result;
await this.assetService.checkAssetsAccess(authUser, dto.ids, true);
return this.assetService.deleteAll(authUser, dto);
}
/**

View 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;
}
}

View File

@ -3,8 +3,6 @@ import { AssetService } from './asset.service';
import { AssetController } from './asset.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetEntity } from '@app/infra';
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { CommunicationModule } from '../communication/communication.module';
import { AssetRepository, IAssetRepository } from './asset-repository';
import { DownloadModule } from '../../modules/download/download.module';
@ -21,14 +19,13 @@ const ASSET_REPOSITORY_PROVIDER = {
imports: [
TypeOrmModule.forFeature([AssetEntity]),
CommunicationModule,
BackgroundTaskModule,
DownloadModule,
TagModule,
StorageModule,
forwardRef(() => AlbumModule),
],
controllers: [AssetController],
providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER],
providers: [AssetService, ASSET_REPOSITORY_PROVIDER],
exports: [ASSET_REPOSITORY_PROVIDER],
})
export class AssetModule {}

View File

@ -1,17 +1,15 @@
import { IAssetRepository } from './asset-repository';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { AssetService } from './asset.service';
import { Repository } from 'typeorm';
import { QueryFailedError, Repository } from 'typeorm';
import { AssetEntity, AssetType } from '@app/infra';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { DownloadService } from '../../modules/download/download.service';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
import { ICryptoRepository, IJobRepository, ISharedLinkRepository } from '@app/domain';
import { ICryptoRepository, IJobRepository, ISharedLinkRepository, JobName } from '@app/domain';
import {
authStub,
newCryptoRepositoryMock,
@ -23,105 +21,102 @@ import {
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
const _getCreateAssetDto = (): CreateAssetDto => {
const createAssetDto = new CreateAssetDto();
createAssetDto.deviceAssetId = 'deviceAssetId';
createAssetDto.deviceId = 'deviceId';
createAssetDto.assetType = AssetType.OTHER;
createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
createAssetDto.isFavorite = false;
createAssetDto.duration = '0:00:00.000000';
return createAssetDto;
};
const _getAsset_1 = () => {
const asset_1 = new AssetEntity();
asset_1.id = 'id_1';
asset_1.userId = 'user_id_1';
asset_1.deviceAssetId = 'device_asset_id_1';
asset_1.deviceId = 'device_id_1';
asset_1.type = AssetType.VIDEO;
asset_1.originalPath = 'fake_path/asset_1.jpeg';
asset_1.resizePath = '';
asset_1.createdAt = '2022-06-19T23:41:36.910Z';
asset_1.modifiedAt = '2022-06-19T23:41:36.910Z';
asset_1.isFavorite = false;
asset_1.mimeType = 'image/jpeg';
asset_1.webpPath = '';
asset_1.encodedVideoPath = '';
asset_1.duration = '0:00:00.000000';
return asset_1;
};
const _getAsset_2 = () => {
const asset_2 = new AssetEntity();
asset_2.id = 'id_2';
asset_2.userId = 'user_id_1';
asset_2.deviceAssetId = 'device_asset_id_2';
asset_2.deviceId = 'device_id_1';
asset_2.type = AssetType.VIDEO;
asset_2.originalPath = 'fake_path/asset_2.jpeg';
asset_2.resizePath = '';
asset_2.createdAt = '2022-06-19T23:41:36.910Z';
asset_2.modifiedAt = '2022-06-19T23:41:36.910Z';
asset_2.isFavorite = false;
asset_2.mimeType = 'image/jpeg';
asset_2.webpPath = '';
asset_2.encodedVideoPath = '';
asset_2.duration = '0:00:00.000000';
return asset_2;
};
const _getAssets = () => {
return [_getAsset_1(), _getAsset_2()];
};
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
const result1 = new AssetCountByTimeBucket();
result1.count = 2;
result1.timeBucket = '2022-06-01T00:00:00.000Z';
const result2 = new AssetCountByTimeBucket();
result1.count = 5;
result1.timeBucket = '2022-07-01T00:00:00.000Z';
return [result1, result2];
};
const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
const result = new AssetCountByUserIdResponseDto();
result.videos = 2;
result.photos = 2;
return result;
};
describe('AssetService', () => {
let sui: AssetService;
let sut: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
let storageSeriveMock: jest.Mocked<StorageService>;
let storageServiceMock: jest.Mocked<StorageService>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
const authUser: AuthUserDto = Object.freeze({
id: 'user_id_1',
email: 'auth@test.com',
isAdmin: false,
});
const _getCreateAssetDto = (): CreateAssetDto => {
const createAssetDto = new CreateAssetDto();
createAssetDto.deviceAssetId = 'deviceAssetId';
createAssetDto.deviceId = 'deviceId';
createAssetDto.assetType = AssetType.OTHER;
createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
createAssetDto.isFavorite = false;
createAssetDto.duration = '0:00:00.000000';
return createAssetDto;
};
const _getAsset_1 = () => {
const asset_1 = new AssetEntity();
asset_1.id = 'id_1';
asset_1.userId = 'user_id_1';
asset_1.deviceAssetId = 'device_asset_id_1';
asset_1.deviceId = 'device_id_1';
asset_1.type = AssetType.VIDEO;
asset_1.originalPath = 'fake_path/asset_1.jpeg';
asset_1.resizePath = '';
asset_1.createdAt = '2022-06-19T23:41:36.910Z';
asset_1.modifiedAt = '2022-06-19T23:41:36.910Z';
asset_1.isFavorite = false;
asset_1.mimeType = 'image/jpeg';
asset_1.webpPath = '';
asset_1.encodedVideoPath = '';
asset_1.duration = '0:00:00.000000';
return asset_1;
};
const _getAsset_2 = () => {
const asset_2 = new AssetEntity();
asset_2.id = 'id_2';
asset_2.userId = 'user_id_1';
asset_2.deviceAssetId = 'device_asset_id_2';
asset_2.deviceId = 'device_id_1';
asset_2.type = AssetType.VIDEO;
asset_2.originalPath = 'fake_path/asset_2.jpeg';
asset_2.resizePath = '';
asset_2.createdAt = '2022-06-19T23:41:36.910Z';
asset_2.modifiedAt = '2022-06-19T23:41:36.910Z';
asset_2.isFavorite = false;
asset_2.mimeType = 'image/jpeg';
asset_2.webpPath = '';
asset_2.encodedVideoPath = '';
asset_2.duration = '0:00:00.000000';
return asset_2;
};
const _getAssets = () => {
return [_getAsset_1(), _getAsset_2()];
};
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
const result1 = new AssetCountByTimeBucket();
result1.count = 2;
result1.timeBucket = '2022-06-01T00:00:00.000Z';
const result2 = new AssetCountByTimeBucket();
result1.count = 5;
result1.timeBucket = '2022-07-01T00:00:00.000Z';
return [result1, result2];
};
const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
const result = new AssetCountByUserIdResponseDto();
result.videos = 2;
result.photos = 2;
return result;
};
beforeAll(() => {
beforeEach(() => {
assetRepositoryMock = {
get: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
update: jest.fn(),
getAll: jest.fn(),
getAllVideos: jest.fn(),
@ -151,18 +146,21 @@ describe('AssetService', () => {
downloadArchive: jest.fn(),
};
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
storageServiceMock = {
moveAsset: jest.fn(),
removeEmptyDirectories: jest.fn(),
} as unknown as jest.Mocked<StorageService>;
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
jobMock = newJobRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
sui = new AssetService(
sut = new AssetService(
assetRepositoryMock,
albumRepositoryMock,
a,
backgroundTaskServiceMock,
downloadServiceMock as DownloadService,
storageSeriveMock,
storageServiceMock,
sharedLinkRepositoryMock,
jobMock,
cryptoMock,
@ -178,7 +176,7 @@ describe('AssetService', () => {
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid);
await expect(sui.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
await expect(sut.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(assetRepositoryMock.countByIdAndUser).toHaveBeenCalledWith(asset1.id, authStub.user1.id);
@ -196,7 +194,7 @@ describe('AssetService', () => {
sharedLinkRepositoryMock.get.mockResolvedValue(null);
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
await expect(sui.updateAssetsInSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.updateAssetsInSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
@ -215,7 +213,7 @@ describe('AssetService', () => {
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
sharedLinkRepositoryMock.save.mockResolvedValue(sharedLinkStub.valid);
await expect(sui.updateAssetsInSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
await expect(sut.updateAssetsInSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
@ -223,27 +221,94 @@ describe('AssetService', () => {
});
});
// Currently failing due to calculate checksum from a file
it('create an asset', async () => {
const assetEntity = _getAsset_1();
describe('uploadFile', () => {
it('should handle a file upload', async () => {
const assetEntity = _getAsset_1();
const file = {
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
};
const dto = _getCreateAssetDto();
assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity));
assetRepositoryMock.create.mockImplementation(() => Promise.resolve(assetEntity));
storageServiceMock.moveAsset.mockResolvedValue({ ...assetEntity, originalPath: 'fake_new_path/asset_123.jpeg' });
const originalPath = 'fake_path/asset_1.jpeg';
const mimeType = 'image/jpeg';
const createAssetDto = _getCreateAssetDto();
const result = await sui.createUserAsset(
authUser,
createAssetDto,
originalPath,
mimeType,
Buffer.from('0x5041E6328F7DF8AFF650BEDAED9251897D9A6241', 'hex'),
true,
);
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
});
expect(result.userId).toEqual(authUser.id);
expect(result.resizePath).toEqual('');
expect(result.webpPath).toEqual('');
it('should handle a duplicate', async () => {
const file = {
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
};
const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetByChecksum.mockResolvedValue(_getAsset_1());
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });
expect(jobMock.add).toHaveBeenCalledWith({
name: JobName.DELETE_FILE_ON_DISK,
data: { assets: [{ originalPath: 'fake_path/asset_1.jpeg', resizePath: null }] },
});
expect(storageServiceMock.moveAsset).not.toHaveBeenCalled();
});
it('should handle a live photo', async () => {
const file = {
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
};
const asset = {
id: 'live-photo-asset',
originalPath: file.originalPath,
userId: authStub.user1.id,
type: AssetType.IMAGE,
isVisible: true,
} as AssetEntity;
const livePhotoFile = {
originalPath: 'fake_path/asset_1.mp4',
mimeType: 'image/jpeg',
checksum: Buffer.from('live photo file hash', 'utf8'),
originalName: 'asset_1.jpeg',
};
const livePhotoAsset = {
id: 'live-photo-motion',
originalPath: livePhotoFile.originalPath,
userId: authStub.user1.id,
type: AssetType.VIDEO,
isVisible: false,
} as AssetEntity;
const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockResolvedValueOnce(livePhotoAsset);
assetRepositoryMock.create.mockResolvedValueOnce(asset);
storageServiceMock.moveAsset.mockImplementation((asset) => Promise.resolve(asset));
await expect(sut.uploadFile(authStub.user1, dto, file, livePhotoFile)).resolves.toEqual({
duplicate: false,
id: 'live-photo-asset',
});
expect(jobMock.add.mock.calls).toEqual([
[{ name: JobName.ASSET_UPLOADED, data: { asset: livePhotoAsset, fileName: file.originalName } }],
[{ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } }],
]);
});
});
it('get assets by device id', async () => {
@ -254,7 +319,7 @@ describe('AssetService', () => {
);
const deviceId = 'device_id_1';
const result = await sui.getUserAssetsByDeviceId(authUser, deviceId);
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
expect(result.length).toEqual(2);
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
@ -267,7 +332,7 @@ describe('AssetService', () => {
Promise.resolve<AssetCountByTimeBucket[]>(assetCountByTimeBucket),
);
const result = await sui.getAssetCountByTimeBucket(authUser, {
const result = await sut.getAssetCountByTimeBucket(authStub.user1, {
timeGroup: TimeGroupEnum.Month,
});
@ -282,18 +347,70 @@ describe('AssetService', () => {
Promise.resolve<AssetCountByUserIdResponseDto>(assetCount),
);
const result = await sui.getAssetCountByUserId(authUser);
const result = await sut.getAssetCountByUserId(authStub.user1);
expect(result).toEqual(assetCount);
});
describe('deleteAll', () => {
it('should return failed status when an asset is missing', async () => {
assetRepositoryMock.get.mockResolvedValue(null);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
]);
expect(jobMock.add).not.toHaveBeenCalled();
});
it('should return failed status a delete fails', async () => {
assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity);
assetRepositoryMock.remove.mockRejectedValue('delete failed');
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
]);
expect(jobMock.add).not.toHaveBeenCalled();
});
it('should delete a live photo', async () => {
assetRepositoryMock.get.mockResolvedValueOnce({ id: 'asset1', livePhotoVideoId: 'live-photo' } as AssetEntity);
assetRepositoryMock.get.mockResolvedValueOnce({ id: 'live-photo' } as AssetEntity);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'SUCCESS' },
{ id: 'live-photo', status: 'SUCCESS' },
]);
expect(jobMock.add).toHaveBeenCalledWith({
name: JobName.DELETE_FILE_ON_DISK,
data: { assets: [{ id: 'asset1', livePhotoVideoId: 'live-photo' }, { id: 'live-photo' }] },
});
});
it('should delete a batch of assets', async () => {
assetRepositoryMock.get.mockImplementation((id) => Promise.resolve({ id } as AssetEntity));
assetRepositoryMock.remove.mockImplementation(() => Promise.resolve());
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([
{ id: 'asset1', status: 'SUCCESS' },
{ id: 'asset2', status: 'SUCCESS' },
]);
expect(jobMock.add.mock.calls).toEqual([
[{ name: JobName.DELETE_FILE_ON_DISK, data: { assets: [{ id: 'asset1' }, { id: 'asset2' }] } }],
]);
});
});
describe('checkDownloadAccess', () => {
it('should validate download access', async () => {
await sui.checkDownloadAccess(authStub.adminSharedLink);
await sut.checkDownloadAccess(authStub.adminSharedLink);
});
it('should not allow when user is not allowed to download', async () => {
expect(() => sui.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException);
expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException);
});
});
});

View File

@ -23,8 +23,8 @@ import { SearchAssetDto } from './dto/search-asset.dto';
import fs from 'fs/promises';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '@app/domain';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetResponseDto, JobName, mapAsset, mapAssetWithoutExif } from '@app/domain';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
@ -37,13 +37,12 @@ import {
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { timeUtils } from '@app/common/utils';
import { AssetCore } from './asset.core';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { ICryptoRepository, IJobRepository, JobName } from '@app/domain';
import { ICryptoRepository, IJobRepository } from '@app/domain';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto';
import { IAlbumRepository } from '../album/album-repository';
@ -55,7 +54,6 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { ImmichFile } from '../../config/asset-upload.config';
const fileInfo = promisify(stat);
@ -63,142 +61,69 @@ const fileInfo = promisify(stat);
export class AssetService {
readonly logger = new Logger(AssetService.name);
private shareCore: ShareCore;
private assetCore: AssetCore;
constructor(
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
private backgroundTaskService: BackgroundTaskService,
private downloadService: DownloadService,
private storageService: StorageService,
storageService: StorageService,
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
) {
this.assetCore = new AssetCore(_assetRepository, jobRepository, storageService);
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
}
public async handleUploadedAsset(
public async uploadFile(
authUser: AuthUserDto,
createAssetDto: CreateAssetDto,
res: Res,
originalAssetData: ImmichFile,
livePhotoAssetData?: ImmichFile,
) {
const checksum = originalAssetData.checksum;
const isLivePhoto = livePhotoAssetData !== undefined;
let livePhotoAssetEntity: AssetEntity | undefined;
dto: CreateAssetDto,
file: UploadFile,
livePhotoFile?: UploadFile,
): Promise<AssetFileUploadResponseDto> {
if (livePhotoFile) {
livePhotoFile.originalName = file.originalName;
}
let livePhotoAsset: AssetEntity | null = null;
try {
if (isLivePhoto) {
const livePhotoChecksum = livePhotoAssetData.checksum;
livePhotoAssetEntity = await this.createUserAsset(
authUser,
createAssetDto,
livePhotoAssetData.path,
livePhotoAssetData.mimetype,
livePhotoChecksum,
false,
);
if (!livePhotoAssetEntity) {
await this.backgroundTaskService.deleteFileOnDisk([
{
originalPath: livePhotoAssetData.path,
} as any,
]);
throw new BadRequestException('Asset not created');
}
await this.storageService.moveAsset(livePhotoAssetEntity, originalAssetData.originalname);
await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset: livePhotoAssetEntity } });
if (livePhotoFile) {
const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false };
livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
}
const assetEntity = await this.createUserAsset(
authUser,
createAssetDto,
originalAssetData.path,
originalAssetData.mimetype,
checksum,
true,
livePhotoAssetEntity,
);
if (!assetEntity) {
await this.backgroundTaskService.deleteFileOnDisk([
{
originalPath: originalAssetData.path,
} as any,
]);
throw new BadRequestException('Asset not created');
}
const movedAsset = await this.storageService.moveAsset(assetEntity, originalAssetData.originalname);
const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id);
return { id: asset.id, duplicate: false };
} catch (error: any) {
// clean up files
await this.jobRepository.add({
name: JobName.ASSET_UPLOADED,
data: { asset: movedAsset, fileName: originalAssetData.originalname },
name: JobName.DELETE_FILE_ON_DISK,
data: {
assets: [
{
originalPath: file.originalPath,
resizePath: livePhotoFile?.originalPath || null,
} as AssetEntity,
],
},
});
return new AssetFileUploadResponseDto(movedAsset.id);
} catch (err) {
await this.backgroundTaskService.deleteFileOnDisk([
{
originalPath: originalAssetData.path,
} as any,
]); // simulate asset to make use of delete queue (or use fs.unlink instead)
if (isLivePhoto) {
await this.backgroundTaskService.deleteFileOnDisk([
{
originalPath: livePhotoAssetData.path,
} as any,
]);
// handle duplicates with a success response
if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
const duplicate = await this.getAssetByChecksum(authUser.id, file.checksum);
return { id: duplicate.id, duplicate: true };
}
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
const existedAsset = await this.getAssetByChecksum(authUser.id, checksum);
res.status(200); // normal POST is 201. we use 200 to indicate the asset already exists
return new AssetFileUploadResponseDto(existedAsset.id);
}
Logger.error(`Error uploading file ${err}`);
throw new BadRequestException(`Error uploading file`, `${err}`);
this.logger.error(`Error uploading file ${error}`, error?.stack);
throw new BadRequestException(`Error uploading file`, `${error}`);
}
}
public async createUserAsset(
authUser: AuthUserDto,
createAssetDto: CreateAssetDto,
originalPath: string,
mimeType: string,
checksum: Buffer,
isVisible: boolean,
livePhotoAssetEntity?: AssetEntity,
): Promise<AssetEntity> {
if (!timeUtils.checkValidTimestamp(createAssetDto.createdAt)) {
createAssetDto.createdAt = new Date().toISOString();
}
if (!timeUtils.checkValidTimestamp(createAssetDto.modifiedAt)) {
createAssetDto.modifiedAt = new Date().toISOString();
}
const assetEntity = await this._assetRepository.create(
createAssetDto,
authUser.id,
originalPath,
mimeType,
isVisible,
checksum,
livePhotoAssetEntity,
);
return assetEntity;
}
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
}
@ -520,26 +445,35 @@ export class AssetService {
}
}
public async deleteAssetById(assetIds: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
const deleteQueue: AssetEntity[] = [];
const result: DeleteAssetResponseDto[] = [];
const target = assetIds.ids;
for (const assetId of target) {
const res = await this.assetRepository.delete({
id: assetId,
});
if (res.affected) {
result.push({
id: assetId,
status: DeleteAssetStatusEnum.SUCCESS,
});
} else {
result.push({
id: assetId,
status: DeleteAssetStatusEnum.FAILED,
});
const ids = dto.ids.slice();
for (const id of ids) {
const asset = await this._assetRepository.get(id);
if (!asset) {
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
continue;
}
try {
await this._assetRepository.remove(asset);
result.push({ id: asset.id, status: DeleteAssetStatusEnum.SUCCESS });
deleteQueue.push(asset as any);
// TODO refactor this to use cascades
if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
ids.push(asset.livePhotoVideoId);
}
} catch {
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
}
}
if (deleteQueue.length > 0) {
await this.jobRepository.add({ name: JobName.DELETE_FILE_ON_DISK, data: { assets: deleteQueue } });
}
return result;

View File

@ -1,6 +1,7 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
import { AssetType } from '@app/infra';
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
import { ImmichFile } from '../../../config/asset-upload.config';
export class CreateAssetDto {
@IsNotEmpty()
@ -22,9 +23,29 @@ export class CreateAssetDto {
@IsNotEmpty()
isFavorite!: boolean;
@IsOptional()
@IsBoolean()
isVisible?: boolean;
@IsNotEmpty()
fileExtension!: string;
@IsOptional()
duration?: string;
}
export interface UploadFile {
mimeType: string;
checksum: Buffer;
originalPath: string;
originalName: string;
}
export function mapToUploadFile(file: ImmichFile): UploadFile {
return {
checksum: file.checksum,
mimeType: file.mimetype,
originalPath: file.path,
originalName: file.originalname,
};
}

View File

@ -1,7 +1,4 @@
export class AssetFileUploadResponseDto {
constructor(id: string) {
this.id = id;
}
id: string;
id!: string;
duplicate!: boolean;
}

View File

@ -19,7 +19,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
async handleConnection(client: Socket) {
try {
this.logger.log(`New websocket connection: ${client.id}`);
const user = await this.authService.validate(client.request.headers);
const user = await this.authService.validate(client.request.headers, {});
if (user) {
client.join(user.id);
} else {

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -1,10 +1,8 @@
import { immichAppConfig } from '@app/common/config';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AssetModule } from './api-v1/asset/asset.module';
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
import { ConfigModule } from '@nestjs/config';
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
import { CommunicationModule } from './api-v1/communication/communication.module';
import { AlbumModule } from './api-v1/album/album.module';
import { AppController } from './app.controller';
@ -17,14 +15,14 @@ import { InfraModule } from '@app/infra';
import {
APIKeyController,
AuthController,
DeviceInfoController,
OAuthController,
ShareController,
SystemConfigController,
UserController,
} from './controllers';
import { PublicShareStrategy } from './modules/immich-auth/strategies/public-share.strategy';
import { APIKeyStrategy } from './modules/immich-auth/strategies/api-key.strategy';
import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.strategy';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './middlewares/auth.guard';
@Module({
imports: [
@ -36,12 +34,8 @@ import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.str
AssetModule,
DeviceInfoModule,
ServerInfoModule,
BackgroundTaskModule,
CommunicationModule,
AlbumModule,
@ -59,12 +53,13 @@ import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.str
AppController,
APIKeyController,
AuthController,
DeviceInfoController,
OAuthController,
ShareController,
SystemConfigController,
UserController,
],
providers: [UserAuthStrategy, APIKeyStrategy, PublicShareStrategy],
providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard],
})
export class AppModule implements NestModule {
// TODO: check if consumer is needed or remove

View 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);
}
}

View File

@ -1,5 +1,6 @@
export * from './api-key.controller';
export * from './auth.controller';
export * from './device-info.controller';
export * from './oauth.controller';
export * from './share.controller';
export * from './system-config.controller';

View File

@ -1,25 +1,28 @@
import { UseGuards } from '@nestjs/common';
import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware';
import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware';
import { AuthGuard } from '../modules/immich-auth/guards/auth.guard';
import { applyDecorators, SetMetadata } from '@nestjs/common';
interface AuthenticatedOptions {
admin?: boolean;
isShared?: boolean;
}
export enum Metadata {
AUTH_ROUTE = 'auth_route',
ADMIN_ROUTE = 'admin_route',
SHARED_ROUTE = 'shared_route',
}
export const Authenticated = (options?: AuthenticatedOptions) => {
const guards: Parameters<typeof UseGuards> = [AuthGuard];
const decorators = [SetMetadata(Metadata.AUTH_ROUTE, true)];
options = options || {};
if (options.admin) {
guards.push(AdminRolesGuard);
decorators.push(SetMetadata(Metadata.ADMIN_ROUTE, true));
}
if (!options.isShared) {
guards.push(RouteNotSharedGuard);
if (options.isShared) {
decorators.push(SetMetadata(Metadata.SHARED_ROUTE, true));
}
return UseGuards(...guards);
return applyDecorators(...decorators);
};

View File

@ -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;
}
}

View 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;
}
}

View File

@ -7,15 +7,20 @@ const redisHost = process.env.REDIS_HOSTNAME || 'immich_redis';
const redisPort = parseInt(process.env.REDIS_PORT || '6379');
const redisDb = parseInt(process.env.REDIS_DBINDEX || '0');
const redisPassword = process.env.REDIS_PASSWORD || undefined;
// const redisSocket = process.env.REDIS_SOCKET || undefined;
const redisSocket = process.env.REDIS_SOCKET || undefined;
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: any;
async connectToRedis(): Promise<void> {
const pubClient = createClient({
url: `redis://${redisHost}:${redisPort}/${redisDb}`,
password: redisPassword,
database: redisDb,
socket: {
host: redisHost,
port: redisPort,
path: redisSocket,
},
});
const subClient = pubClient.duplicate();

View File

@ -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;
}
}

View File

@ -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 {}

View File

@ -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 } });
}
}

View File

@ -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]) {}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -2,11 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { clearDb, getAuthUser, authCustom } from './test-utils';
import { InfraModule } from '@app/infra';
import { AlbumModule } from '../src/api-v1/album/album.module';
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
import { AuthService, DomainModule, UserService } from '@app/domain';
import { AuthService, UserService } from '@app/domain';
import { DataSource } from 'typeorm';
import { AppModule } from '../src/app.module';
@ -20,9 +18,7 @@ describe('Album', () => {
describe('without auth', () => {
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
}).compile();
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleFixture.createNestApplication();
database = app.get(DataSource);
@ -46,9 +42,7 @@ describe('Album', () => {
let authService: AuthService;
beforeAll(async () => {
const builder = Test.createTestingModule({
imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule],
});
const builder = Test.createTestingModule({ imports: [AppModule] });
authUser = getAuthUser(); // set default auth user
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();

View File

@ -2,7 +2,7 @@ import { CanActivate, ExecutionContext } from '@nestjs/common';
import { TestingModuleBuilder } from '@nestjs/testing';
import { DataSource } from 'typeorm';
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
import { AuthGuard } from '../src/modules/immich-auth/guards/auth.guard';
import { AuthGuard } from '../src/middlewares/auth.guard';
type CustomAuthCallback = () => AuthUserDto;
@ -34,5 +34,5 @@ export function authCustom(builder: TestingModuleBuilder, callback: CustomAuthCa
return true;
},
};
return builder.overrideGuard(AuthGuard).useValue(canActivate);
return builder.overrideProvider(AuthGuard).useValue(canActivate);
}

View File

@ -2,10 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { clearDb, authCustom } from './test-utils';
import { InfraModule } from '@app/infra';
import { DomainModule, CreateUserDto, UserService, AuthUserDto } from '@app/domain';
import { CreateUserDto, UserService, AuthUserDto } from '@app/domain';
import { DataSource } from 'typeorm';
import { UserController } from '../src/controllers';
import { AuthService } from '@app/domain';
import { AppModule } from '../src/app.module';
@ -24,10 +22,7 @@ describe('User', () => {
describe('without auth', () => {
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
controllers: [UserController],
}).compile();
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleFixture.createNestApplication();
database = app.get(DataSource);
@ -50,10 +45,7 @@ describe('User', () => {
let authUser: AuthUserDto;
beforeAll(async () => {
const builder = Test.createTestingModule({
imports: [DomainModule.register({ imports: [InfraModule] })],
controllers: [UserController],
});
const builder = Test.createTestingModule({ imports: [AppModule] });
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
app = moduleFixture.createNestApplication();

View File

@ -14,6 +14,7 @@ import { StorageMigrationProcessor } from './processors/storage-migration.proces
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
import { UserDeletionProcessor } from './processors/user-deletion.processor';
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
import { BackgroundTaskProcessor } from './processors/background-task.processor';
import { DomainModule } from '@app/domain';
@Module({
@ -37,6 +38,7 @@ import { DomainModule } from '@app/domain';
MachineLearningProcessor,
UserDeletionProcessor,
StorageMigrationProcessor,
BackgroundTaskProcessor,
],
})
export class MicroservicesModule {}

View File

@ -2,7 +2,7 @@ import { assetUtils } from '@app/common/utils';
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import { JobName, QueueName } from '@app/domain';
import { AssetEntity } from '@app/infra';
import { AssetEntity } from '@app/infra/db/entities';
@Processor(QueueName.BACKGROUND_TASK)
export class BackgroundTaskProcessor {

View File

@ -235,6 +235,10 @@ export class MetadataExtractionProcessor {
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
const { asset, fileName } = job.data;
if (!asset.isVisible) {
return;
}
try {
const data = await new Promise<ffmpeg.FfprobeData>((resolve, reject) =>
ffmpeg.ffprobe(asset.originalPath, (err, data) => {

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