Compare commits

...

22 Commits

Author SHA1 Message Date
mertalev
e7404e7495 dynamic thumbnail resolution 2025-09-19 10:55:55 -04:00
mertalev
7cd5b7f64d remove timer 2025-09-19 10:51:58 -04:00
mertalev
fb798a3492 add unit comment 2025-09-19 10:51:58 -04:00
mertalev
73d650de23 simplify 2025-09-19 10:51:58 -04:00
mertalev
a07f3c2ba4 make resolver configurable 2025-09-19 10:51:58 -04:00
mertalev
9ccd98d871 tweaks 2025-09-19 10:51:58 -04:00
mertalev
188dcbf7d0 check cancellation before queueing 2025-09-19 10:51:58 -04:00
mertalev
51d106d192 refactor
handle missing asset
2025-09-19 10:51:58 -04:00
mertalev
3e427e42cb dynamically batch asset requests 2025-09-19 10:51:58 -04:00
mertalev
f6a99602e9 separate asset and thumbnail concurrency 2025-09-19 10:51:58 -04:00
Mert
52363cf0fb chore(mobile): ignore ios build folder (#22212)
ignore ios build folder
2025-09-19 09:50:24 -05:00
Jason Rasmussen
86df09a0e4 fix(mobile): smaller search page size (#22210) 2025-09-19 10:11:11 -04:00
shenlong
e1e24f3d60 fix: sqlite parameters limit (#22119)
* fix isNotIns

* fix isIns

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-09-19 09:47:56 -04:00
Alex
33d76fb386 fix: download feedback (#22178)
* fix: download feedback

* chore: use FAB for asset viewer as well
2025-09-19 00:47:01 -05:00
Alex
642065f506 fix: get scrubber in search view working (#22175)
* feat: add option to disable snapping

* handle offset when there is no appbar
2025-09-19 00:20:09 -05:00
Jason Rasmussen
de897f6069 fix(web): do not upscale small pictures (#22191) 2025-09-18 22:28:06 -04:00
Jason Rasmussen
68f3ed89c5 chore: remove unused init service (#22188) 2025-09-19 01:11:45 +00:00
Sergey Katsubo
78516a97b3 chore(server): proper log context formatting (#22173)
* Fix log formatting for logger.error(..., error)

Rewrite it to avoid printing error msg in [context]

* Fix log formatting for logger.warn(..., error?.stack)

Rewrite it to avoid printing stack in [context]

* Fix log formatting for logger.debug(..., error.message);

Rewrite it to avoid printing error msg in [context]

* Print error msg instead of literal "Error"
2025-09-18 19:56:05 -04:00
Alex
b8a17c3c26 fix: disable scrubbing mode on drag ended (#22186) 2025-09-18 16:42:33 -05:00
Alex
e42886b767 fix: display thumbnail while scrubbing paused (#22164)
* fix: display thumbnail while scrubbing paused

* pr feedback

* pr feedback

* tune timeout
2025-09-18 20:59:58 +00:00
Alex
d36c26bf97 chore: refresh backup stats when entering backup page (#21977)
* chore: refresh backup stats when entering backup page

* check for success status

* remove logs

* remove sync remote when toggle the button

* show status immediately after navigating to screen

* pr feedback
2025-09-18 15:36:43 -05:00
Brandon Wees
dcbc266b83 chore: disable mise lockfile (#22182) 2025-09-18 15:44:33 -04:00
56 changed files with 8598 additions and 427 deletions

View File

@@ -5,8 +5,7 @@
"immich-server", "immich-server",
"redis", "redis",
"database", "database",
"immich-machine-learning", "immich-machine-learning"
"init"
], ],
"dockerComposeFile": [ "dockerComposeFile": [
"../docker/docker-compose.dev.yml", "../docker/docker-compose.dev.yml",

1
.gitignore vendored
View File

@@ -18,6 +18,7 @@ mobile/libisar.dylib
mobile/openapi/test mobile/openapi/test
mobile/openapi/doc mobile/openapi/doc
mobile/openapi/.openapi-generator/FILES mobile/openapi/.openapi-generator/FILES
mobile/ios/build
open-api/typescript-sdk/build open-api/typescript-sdk/build
mobile/android/fastlane/report.xml mobile/android/fastlane/report.xml

View File

@@ -1,34 +0,0 @@
[tools.dart]
version = "3.8.2"
backend = "asdf:dart"
[tools.flutter]
version = "3.35.3-stable"
backend = "asdf:flutter"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.31.4"
backend = "github:CQLabs/homebrew-dcm"
[tools."github:CQLabs/homebrew-dcm".platforms.linux-x64]
checksum = "blake3:e9df5b765df327e1248fccf2c6165a89d632a065667f99c01765bf3047b94955"
size = 8821083
url = "https://github.com/CQLabs/homebrew-dcm/releases/download/1.31.4/dcm-linux-x64-release.zip"
[tools.node]
version = "22.18.0"
backend = "core:node"
[tools.node.platforms.linux-x64]
checksum = "sha256:a2e703725d8683be86bb5da967bf8272f4518bdaf10f21389e2b2c9eaeae8c8a"
size = 54824343
url = "https://nodejs.org/dist/v22.18.0/node-v22.18.0-linux-x64.tar.gz"
[tools.pnpm]
version = "10.14.0"
backend = "aqua:pnpm/pnpm"
[tools.pnpm.platforms.linux-x64]
checksum = "blake3:13dfa46b7173d3cad3bad60a756a492ecf0bce48b23eb9f793e7ccec5a09b46d"
size = 66231525
url = "https://github.com/pnpm/pnpm/releases/download/v10.14.0/pnpm-linux-x64"

View File

@@ -11,7 +11,6 @@ postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm"
[settings] [settings]
experimental = true experimental = true
lockfile = true
pin = true pin = true
# .github # .github

View File

@@ -3,14 +3,19 @@ package app.alextran.immich.images
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.res.Resources
import android.graphics.* import android.graphics.*
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal import android.os.CancellationSignal
import android.os.OperationCanceledException import android.os.OperationCanceledException
import android.provider.DocumentsContract
import android.provider.MediaStore.Images import android.provider.MediaStore.Images
import android.provider.MediaStore.Video import android.provider.MediaStore.Video
import android.system.Int64Ref
import android.util.Size import android.util.Size
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.math.* import kotlin.math.*
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -172,7 +177,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
} }
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal) loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
} else { } else {
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) } signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS) Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
@@ -185,7 +190,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
signal.throwIfCanceled() signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id) val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id)
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal) loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
} else { } else {
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) } signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) }
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS) Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS)
@@ -215,4 +220,72 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
ref.get() ref.get()
} }
} }
/*
* Copyright (C) 2006 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@RequiresApi(Build.VERSION_CODES.Q)
fun loadThumbnail(uri: Uri, size: Size, signal: CancellationSignal?): Bitmap {
// Convert to Point, since that's what the API is defined as
val opts = Bundle()
if (size.width < 512 && size.height < 512) {
opts.putParcelable(ContentResolver.EXTRA_SIZE, Point(size.width, size.height))
}
val orientation = Int64Ref(0)
var bitmap =
ImageDecoder.decodeBitmap(
ImageDecoder.createSource {
val afd =
resolver.openTypedAssetFile(uri, "image/*", opts, signal)
?: throw Resources.NotFoundException("Asset $uri not found")
val extras = afd.extras
orientation.value =
(extras?.getInt(DocumentsContract.EXTRA_ORIENTATION, 0) ?: 0).toLong()
afd
}
) { decoder: ImageDecoder, info: ImageDecoder.ImageInfo, _: ImageDecoder.Source ->
decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE)
// One last-ditch check to see if we've been canceled.
signal?.throwIfCanceled()
// We requested a rough thumbnail size, but the remote size may have
// returned something giant, so defensively scale down as needed.
// This is modified from the original to target the smaller edge instead of the larger edge.
val widthSample = info.size.width.toDouble() / size.width
val heightSample = info.size.height.toDouble() / size.height
val sample = min(widthSample, heightSample)
if (sample > 1) {
val width = (info.size.width / sample).toInt()
val height = (info.size.height / sample).toInt()
decoder.setTargetSize(width, height)
}
}
// Transform the bitmap if requested. We use a side-channel to
// communicate the orientation, since EXIF thumbnails don't contain
// the rotation flags of the original image.
if (orientation.value != 0L) {
val width = bitmap.getWidth()
val height = bitmap.getHeight()
val m = Matrix()
m.setRotate(orientation.value.toFloat(), (width / 2).toFloat(), (height / 2).toFloat())
bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, m, false)
}
return bitmap
}
} }

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 77; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -29,9 +29,11 @@
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; }; FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; }; FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; }; FEC340D12E7326630050078A /* AssetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340C92E7326630050078A /* AssetResolver.swift */; };
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; }; FEC340D22E7326630050078A /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CB2E7326630050078A /* Thumbhash.swift */; };
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; }; FEC340D32E7326630050078A /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CF2E7326630050078A /* Request.swift */; };
FEC340D42E7326630050078A /* ThumbnailResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CC2E7326630050078A /* ThumbnailResolver.swift */; };
FEC340D52E7326630050078A /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CD2E7326630050078A /* Thumbnails.g.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -115,9 +117,11 @@
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; }; FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; }; FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; }; FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; }; FEC340C92E7326630050078A /* AssetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResolver.swift; sourceTree = "<group>"; };
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; }; FEC340CB2E7326630050078A /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = "<group>"; }; FEC340CC2E7326630050078A /* ThumbnailResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailResolver.swift; sourceTree = "<group>"; };
FEC340CD2E7326630050078A /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
FEC340CF2E7326630050078A /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -133,6 +137,8 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync; path = Sync;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -245,6 +251,7 @@
97C146F01CF9000F007C117D /* Runner */ = { 97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FEC340D02E7326630050078A /* Resolvers */,
B25D37792E72CA15008B6CA7 /* Connectivity */, B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */, B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
@@ -259,7 +266,6 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FED3B1952E253E9B0030FD97 /* Images */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -294,16 +300,34 @@
path = ShareExtension; path = ShareExtension;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
FED3B1952E253E9B0030FD97 /* Images */ = { FEC340CA2E7326630050078A /* Assets */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */, FEC340C92E7326630050078A /* AssetResolver.swift */,
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */, );
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */, path = Assets;
sourceTree = "<group>";
};
FEC340CE2E7326630050078A /* Images */ = {
isa = PBXGroup;
children = (
FEC340CB2E7326630050078A /* Thumbhash.swift */,
FEC340CC2E7326630050078A /* ThumbnailResolver.swift */,
FEC340CD2E7326630050078A /* Thumbnails.g.swift */,
); );
path = Images; path = Images;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
FEC340D02E7326630050078A /* Resolvers */ = {
isa = PBXGroup;
children = (
FEC340CA2E7326630050078A /* Assets */,
FEC340CE2E7326630050078A /* Images */,
FEC340CF2E7326630050078A /* Request.swift */,
);
path = Resolvers;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -519,14 +543,10 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -555,14 +575,10 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -579,14 +595,16 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */, B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */, B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */, B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */, B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */, B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */,
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */, 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
FEC340D12E7326630050078A /* AssetResolver.swift in Sources */,
FEC340D22E7326630050078A /* Thumbhash.swift in Sources */,
FEC340D32E7326630050078A /* Request.swift in Sources */,
FEC340D42E7326630050078A /* ThumbnailResolver.swift in Sources */,
FEC340D52E7326630050078A /* Thumbnails.g.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@@ -15,7 +15,7 @@ import UIKit
) -> Bool { ) -> Bool {
// Required for flutter_local_notification // Required for flutter_local_notification
if #available(iOS 10.0, *) { if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
} }
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
@@ -53,7 +53,7 @@ import UIKit
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) { public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl()) NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl()) ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailResolver())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl())
} }
} }

View File

@@ -1,211 +0,0 @@
import CryptoKit
import Flutter
import MobileCoreServices
import Photos
class Request {
weak var workItem: DispatchWorkItem?
var isCancelled = false
let callback: (Result<[String: Int64], any Error>) -> Void
init(callback: @escaping (Result<[String: Int64], any Error>) -> Void) {
self.callback = callback
}
}
class ThumbnailApiImpl: ThumbnailApi {
private static let imageManager = PHImageManager.default()
private static let fetchOptions = {
let fetchOptions = PHFetchOptions()
fetchOptions.fetchLimit = 1
fetchOptions.wantsIncrementalChangeDetails = false
return fetchOptions
}()
private static let requestOptions = {
let requestOptions = PHImageRequestOptions()
requestOptions.isNetworkAccessAllowed = true
requestOptions.deliveryMode = .highQualityFormat
requestOptions.resizeMode = .fast
requestOptions.isSynchronous = true
requestOptions.version = .current
return requestOptions
}()
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
private static var requests = [Int64: Request]()
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
private static let assetCache = {
let assetCache = NSCache<NSString, PHAsset>()
assetCache.countLimit = 10000
return assetCache
}()
private static let activitySemaphore = DispatchSemaphore(value: 1)
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.suspend()
activitySemaphore.wait()
}
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.resume()
activitySemaphore.signal()
}
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
let request = Request(callback: completion)
let item = DispatchWorkItem {
if request.isCancelled {
return completion(Self.cancelledResult)
}
Self.concurrencySemaphore.wait()
defer {
Self.concurrencySemaphore.signal()
}
if request.isCancelled {
return completion(Self.cancelledResult)
}
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.removeRequest(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
if request.isCancelled {
return completion(Self.cancelledResult)
}
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
image = _image
}
)
if request.isCancelled {
return completion(Self.cancelledResult)
}
guard let image = image,
let cgImage = image.cgImage else {
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
alignment: MemoryLayout<UInt8>.alignment
)
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
guard let context = CGContext(
data: pointer,
width: cgImage.width,
height: cgImage.height,
bitsPerComponent: 8,
bytesPerRow: cgImage.width * 4,
space: Self.rgbColorSpace,
bitmapInfo: Self.bitmapInfo
) else {
pointer.deallocate()
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
}
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
context.interpolationQuality = .none
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
Self.removeRequest(requestId: requestId)
}
request.workItem = item
Self.addRequest(requestId: requestId, request: request)
Self.processingQueue.async(execute: item)
}
func cancelImageRequest(requestId: Int64) {
Self.cancelRequest(requestId: requestId)
}
private static func addRequest(requestId: Int64, request: Request) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func removeRequest(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancelRequest(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
guard let item = request.workItem else { return }
if item.isCancelled {
cancelQueue.async { request.callback(Self.cancelledResult) }
}
}
}
private static func requestAsset(assetId: String) -> PHAsset? {
var asset: PHAsset?
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
if asset != nil { return asset }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
else { return nil }
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
return asset
}
func waitForActiveState() {
Self.activitySemaphore.wait()
Self.activitySemaphore.signal()
}
}

View File

@@ -0,0 +1,115 @@
import Photos
class AssetRequest: Request {
let assetId: String
var completion: (PHAsset?) -> Void
init(cancellationToken: CancellationToken, assetId: String, completion: @escaping (PHAsset?) -> Void) {
self.assetId = assetId
self.completion = completion
super.init(cancellationToken: cancellationToken)
}
}
class AssetResolver {
private let requestQueue: DispatchQueue
private let processingQueue: DispatchQueue
private var batchTimer: DispatchWorkItem?
private let batchLock = NSLock()
private let batchTimeout: TimeInterval
private let fetchOptions: PHFetchOptions
private var assetRequests = [AssetRequest]()
private let assetCache: NSCache<NSString, PHAsset>
init(
fetchOptions: PHFetchOptions,
batchTimeout: TimeInterval = 0.00025, // 250μs
cacheSize: Int = 10000,
qos: DispatchQoS = .unspecified
) {
self.fetchOptions = fetchOptions
self.batchTimeout = batchTimeout
self.assetCache = NSCache<NSString, PHAsset>()
self.assetCache.countLimit = cacheSize
self.requestQueue = DispatchQueue(label: "assets.requests", qos: qos)
self.processingQueue = DispatchQueue(label: "assets.processing", qos: qos)
}
func requestAsset(request: AssetRequest) {
requestQueue.async {
if (request.isCancelled) {
request.completion(nil)
return
}
if let cachedAsset = self.assetCache.object(forKey: request.assetId as NSString) {
request.completion(cachedAsset)
return
}
self.batchLock.lock()
if (request.isCancelled) {
self.batchLock.unlock()
request.completion(nil)
return
}
self.assetRequests.append(request)
self.batchTimer?.cancel()
let timer = DispatchWorkItem(block: self.processBatch)
self.batchTimer = timer
self.batchLock.unlock()
self.processingQueue.asyncAfter(deadline: .now() + self.batchTimeout, execute: timer)
}
}
private func processBatch() {
batchLock.lock()
if assetRequests.isEmpty {
batchTimer = nil
batchLock.unlock()
return
}
var completionMap = [String: [(PHAsset?) -> Void]]()
var activeAssetIds = [String]()
completionMap.reserveCapacity(assetRequests.count)
activeAssetIds.reserveCapacity(assetRequests.count)
for request in assetRequests {
if (request.isCancelled) {
request.completion(nil)
continue
}
if var completions = completionMap[request.assetId] {
completions.append(request.completion)
} else {
activeAssetIds.append(request.assetId)
completionMap[request.assetId] = [request.completion]
}
}
assetRequests.removeAll(keepingCapacity: true)
batchTimer = nil
batchLock.unlock()
guard !activeAssetIds.isEmpty else { return }
let assets = PHAsset.fetchAssets(withLocalIdentifiers: activeAssetIds, options: self.fetchOptions)
assets.enumerateObjects { asset, _, _ in
let assetId = asset.localIdentifier
for completion in completionMap.removeValue(forKey: assetId)! {
completion(asset)
}
self.requestQueue.async { self.assetCache.setObject(asset, forKey: assetId as NSString) }
}
for completions in completionMap.values {
for completion in completions {
completion(nil)
}
}
}
}

View File

@@ -0,0 +1,198 @@
import CryptoKit
import Flutter
import MobileCoreServices
import Photos
class ThumbnailRequest: Request {
weak var workItem: DispatchWorkItem?
let completion: (Result<[String: Int64], any Error>) -> Void
init(cancellationToken: CancellationToken, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
self.completion = completion
super.init(cancellationToken: cancellationToken)
}
}
class ThumbnailResolver: ThumbnailApi {
private static let imageManager = PHImageManager.default()
private static let assetResolver = AssetResolver(fetchOptions: {
let fetchOptions = PHFetchOptions()
fetchOptions.wantsIncrementalChangeDetails = false
return fetchOptions
}(), qos: .userInitiated)
private static let requestOptions = {
let requestOptions = PHImageRequestOptions()
requestOptions.isNetworkAccessAllowed = true
requestOptions.deliveryMode = .highQualityFormat
requestOptions.resizeMode = .fast
requestOptions.isSynchronous = true
requestOptions.version = .current
return requestOptions
}()
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
private static var requests = [Int64: ThumbnailRequest]()
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
private static let thumbnailConcurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount / 2 + 1)
private static let activitySemaphore = DispatchSemaphore(value: 1)
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.suspend()
activitySemaphore.wait()
}
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.resume()
activitySemaphore.signal()
}
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
let cancellationToken = CancellationToken()
let thumbnailRequest = ThumbnailRequest(cancellationToken: cancellationToken, completion: completion)
Self.assetResolver.requestAsset(request: AssetRequest(cancellationToken: cancellationToken, assetId: assetId) { asset in
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
let item = DispatchWorkItem {
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
guard let asset = asset else {
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
Self.removeRequest(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
Self.thumbnailConcurrencySemaphore.wait()
defer { Self.thumbnailConcurrencySemaphore.signal() }
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
image = _image
}
)
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
guard let image = image,
let cgImage = image.cgImage else {
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
alignment: MemoryLayout<UInt8>.alignment
)
if cancellationToken.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
guard let context = CGContext(
data: pointer,
width: cgImage.width,
height: cgImage.height,
bitsPerComponent: 8,
bytesPerRow: cgImage.width * 4,
space: Self.rgbColorSpace,
bitmapInfo: Self.bitmapInfo
) else {
pointer.deallocate()
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
}
if cancellationToken.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
context.interpolationQuality = .none
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
if cancellationToken.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
Self.removeRequest(requestId: requestId)
}
thumbnailRequest.workItem = item
Self.processingQueue.async(execute: item)
})
Self.addRequest(requestId: requestId, request: thumbnailRequest)
}
func cancelImageRequest(requestId: Int64) {
Self.cancelRequest(requestId: requestId)
}
private static func addRequest(requestId: Int64, request: ThumbnailRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func removeRequest(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancelRequest(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
guard let item = request.workItem else { return }
item.cancel()
if item.isCancelled {
cancelQueue.async { request.completion(Self.cancelledResult) }
}
}
}
func waitForActiveState() {
Self.activitySemaphore.wait()
Self.activitySemaphore.signal()
}
}

View File

@@ -0,0 +1,20 @@
class CancellationToken {
var isCancelled = false
}
class Request {
let cancellationToken: CancellationToken
init(cancellationToken: CancellationToken) {
self.cancellationToken = cancellationToken
}
var isCancelled: Bool {
get {
return cancellationToken.isCancelled
}
set(newValue) {
cancellationToken.isCancelled = newValue
}
}
}

View File

@@ -10,6 +10,9 @@ class LocalAlbumAssetEntity extends Table with DriftDefaultsMixin {
TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)(); TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)();
// Used for mark & sweep
BoolColumn get marker_ => boolean().nullable()();
@override @override
Set<Column> get primaryKey => {assetId, albumId}; Set<Column> get primaryKey => {assetId, albumId};
} }

View File

@@ -15,11 +15,13 @@ typedef $$LocalAlbumAssetEntityTableCreateCompanionBuilder =
i1.LocalAlbumAssetEntityCompanion Function({ i1.LocalAlbumAssetEntityCompanion Function({
required String assetId, required String assetId,
required String albumId, required String albumId,
i0.Value<bool?> marker_,
}); });
typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder = typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder =
i1.LocalAlbumAssetEntityCompanion Function({ i1.LocalAlbumAssetEntityCompanion Function({
i0.Value<String> assetId, i0.Value<String> assetId,
i0.Value<String> albumId, i0.Value<String> albumId,
i0.Value<bool?> marker_,
}); });
final class $$LocalAlbumAssetEntityTableReferences final class $$LocalAlbumAssetEntityTableReferences
@@ -113,6 +115,11 @@ class $$LocalAlbumAssetEntityTableFilterComposer
super.$addJoinBuilderToRootComposer, super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer, super.$removeJoinBuilderFromRootComposer,
}); });
i0.ColumnFilters<bool> get marker_ => $composableBuilder(
column: $table.marker_,
builder: (column) => i0.ColumnFilters(column),
);
i3.$$LocalAssetEntityTableFilterComposer get assetId { i3.$$LocalAssetEntityTableFilterComposer get assetId {
final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder( final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this, composer: this,
@@ -177,6 +184,11 @@ class $$LocalAlbumAssetEntityTableOrderingComposer
super.$addJoinBuilderToRootComposer, super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer, super.$removeJoinBuilderFromRootComposer,
}); });
i0.ColumnOrderings<bool> get marker_ => $composableBuilder(
column: $table.marker_,
builder: (column) => i0.ColumnOrderings(column),
);
i3.$$LocalAssetEntityTableOrderingComposer get assetId { i3.$$LocalAssetEntityTableOrderingComposer get assetId {
final i3.$$LocalAssetEntityTableOrderingComposer composer = final i3.$$LocalAssetEntityTableOrderingComposer composer =
$composerBuilder( $composerBuilder(
@@ -243,6 +255,9 @@ class $$LocalAlbumAssetEntityTableAnnotationComposer
super.$addJoinBuilderToRootComposer, super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer, super.$removeJoinBuilderFromRootComposer,
}); });
i0.GeneratedColumn<bool> get marker_ =>
$composableBuilder(column: $table.marker_, builder: (column) => column);
i3.$$LocalAssetEntityTableAnnotationComposer get assetId { i3.$$LocalAssetEntityTableAnnotationComposer get assetId {
final i3.$$LocalAssetEntityTableAnnotationComposer composer = final i3.$$LocalAssetEntityTableAnnotationComposer composer =
$composerBuilder( $composerBuilder(
@@ -344,16 +359,22 @@ class $$LocalAlbumAssetEntityTableTableManager
({ ({
i0.Value<String> assetId = const i0.Value.absent(), i0.Value<String> assetId = const i0.Value.absent(),
i0.Value<String> albumId = const i0.Value.absent(), i0.Value<String> albumId = const i0.Value.absent(),
i0.Value<bool?> marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumAssetEntityCompanion( }) => i1.LocalAlbumAssetEntityCompanion(
assetId: assetId, assetId: assetId,
albumId: albumId, albumId: albumId,
marker_: marker_,
), ),
createCompanionCallback: createCompanionCallback:
({required String assetId, required String albumId}) => ({
i1.LocalAlbumAssetEntityCompanion.insert( required String assetId,
assetId: assetId, required String albumId,
albumId: albumId, i0.Value<bool?> marker_ = const i0.Value.absent(),
), }) => i1.LocalAlbumAssetEntityCompanion.insert(
assetId: assetId,
albumId: albumId,
marker_: marker_,
),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
.map( .map(
(e) => ( (e) => (
@@ -477,8 +498,22 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
'REFERENCES local_album_entity (id) ON DELETE CASCADE', 'REFERENCES local_album_entity (id) ON DELETE CASCADE',
), ),
); );
static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta(
'marker_',
);
@override @override
List<i0.GeneratedColumn> get $columns => [assetId, albumId]; late final i0.GeneratedColumn<bool> marker_ = i0.GeneratedColumn<bool>(
'marker',
aliasedName,
true,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("marker" IN (0, 1))',
),
);
@override
List<i0.GeneratedColumn> get $columns => [assetId, albumId, marker_];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@override @override
@@ -507,6 +542,12 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
} else if (isInserting) { } else if (isInserting) {
context.missing(_albumIdMeta); context.missing(_albumIdMeta);
} }
if (data.containsKey('marker')) {
context.handle(
_marker_Meta,
marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta),
);
}
return context; return context;
} }
@@ -527,6 +568,10 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
i0.DriftSqlType.string, i0.DriftSqlType.string,
data['${effectivePrefix}album_id'], data['${effectivePrefix}album_id'],
)!, )!,
marker_: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool,
data['${effectivePrefix}marker'],
),
); );
} }
@@ -545,15 +590,20 @@ class LocalAlbumAssetEntityData extends i0.DataClass
implements i0.Insertable<i1.LocalAlbumAssetEntityData> { implements i0.Insertable<i1.LocalAlbumAssetEntityData> {
final String assetId; final String assetId;
final String albumId; final String albumId;
final bool? marker_;
const LocalAlbumAssetEntityData({ const LocalAlbumAssetEntityData({
required this.assetId, required this.assetId,
required this.albumId, required this.albumId,
this.marker_,
}); });
@override @override
Map<String, i0.Expression> toColumns(bool nullToAbsent) { Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{}; final map = <String, i0.Expression>{};
map['asset_id'] = i0.Variable<String>(assetId); map['asset_id'] = i0.Variable<String>(assetId);
map['album_id'] = i0.Variable<String>(albumId); map['album_id'] = i0.Variable<String>(albumId);
if (!nullToAbsent || marker_ != null) {
map['marker'] = i0.Variable<bool>(marker_);
}
return map; return map;
} }
@@ -565,6 +615,7 @@ class LocalAlbumAssetEntityData extends i0.DataClass
return LocalAlbumAssetEntityData( return LocalAlbumAssetEntityData(
assetId: serializer.fromJson<String>(json['assetId']), assetId: serializer.fromJson<String>(json['assetId']),
albumId: serializer.fromJson<String>(json['albumId']), albumId: serializer.fromJson<String>(json['albumId']),
marker_: serializer.fromJson<bool?>(json['marker_']),
); );
} }
@override @override
@@ -573,20 +624,26 @@ class LocalAlbumAssetEntityData extends i0.DataClass
return <String, dynamic>{ return <String, dynamic>{
'assetId': serializer.toJson<String>(assetId), 'assetId': serializer.toJson<String>(assetId),
'albumId': serializer.toJson<String>(albumId), 'albumId': serializer.toJson<String>(albumId),
'marker_': serializer.toJson<bool?>(marker_),
}; };
} }
i1.LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => i1.LocalAlbumAssetEntityData copyWith({
i1.LocalAlbumAssetEntityData( String? assetId,
assetId: assetId ?? this.assetId, String? albumId,
albumId: albumId ?? this.albumId, i0.Value<bool?> marker_ = const i0.Value.absent(),
); }) => i1.LocalAlbumAssetEntityData(
assetId: assetId ?? this.assetId,
albumId: albumId ?? this.albumId,
marker_: marker_.present ? marker_.value : this.marker_,
);
LocalAlbumAssetEntityData copyWithCompanion( LocalAlbumAssetEntityData copyWithCompanion(
i1.LocalAlbumAssetEntityCompanion data, i1.LocalAlbumAssetEntityCompanion data,
) { ) {
return LocalAlbumAssetEntityData( return LocalAlbumAssetEntityData(
assetId: data.assetId.present ? data.assetId.value : this.assetId, assetId: data.assetId.present ? data.assetId.value : this.assetId,
albumId: data.albumId.present ? data.albumId.value : this.albumId, albumId: data.albumId.present ? data.albumId.value : this.albumId,
marker_: data.marker_.present ? data.marker_.value : this.marker_,
); );
} }
@@ -594,51 +651,60 @@ class LocalAlbumAssetEntityData extends i0.DataClass
String toString() { String toString() {
return (StringBuffer('LocalAlbumAssetEntityData(') return (StringBuffer('LocalAlbumAssetEntityData(')
..write('assetId: $assetId, ') ..write('assetId: $assetId, ')
..write('albumId: $albumId') ..write('albumId: $albumId, ')
..write('marker_: $marker_')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@override @override
int get hashCode => Object.hash(assetId, albumId); int get hashCode => Object.hash(assetId, albumId, marker_);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
(other is i1.LocalAlbumAssetEntityData && (other is i1.LocalAlbumAssetEntityData &&
other.assetId == this.assetId && other.assetId == this.assetId &&
other.albumId == this.albumId); other.albumId == this.albumId &&
other.marker_ == this.marker_);
} }
class LocalAlbumAssetEntityCompanion class LocalAlbumAssetEntityCompanion
extends i0.UpdateCompanion<i1.LocalAlbumAssetEntityData> { extends i0.UpdateCompanion<i1.LocalAlbumAssetEntityData> {
final i0.Value<String> assetId; final i0.Value<String> assetId;
final i0.Value<String> albumId; final i0.Value<String> albumId;
final i0.Value<bool?> marker_;
const LocalAlbumAssetEntityCompanion({ const LocalAlbumAssetEntityCompanion({
this.assetId = const i0.Value.absent(), this.assetId = const i0.Value.absent(),
this.albumId = const i0.Value.absent(), this.albumId = const i0.Value.absent(),
this.marker_ = const i0.Value.absent(),
}); });
LocalAlbumAssetEntityCompanion.insert({ LocalAlbumAssetEntityCompanion.insert({
required String assetId, required String assetId,
required String albumId, required String albumId,
this.marker_ = const i0.Value.absent(),
}) : assetId = i0.Value(assetId), }) : assetId = i0.Value(assetId),
albumId = i0.Value(albumId); albumId = i0.Value(albumId);
static i0.Insertable<i1.LocalAlbumAssetEntityData> custom({ static i0.Insertable<i1.LocalAlbumAssetEntityData> custom({
i0.Expression<String>? assetId, i0.Expression<String>? assetId,
i0.Expression<String>? albumId, i0.Expression<String>? albumId,
i0.Expression<bool>? marker_,
}) { }) {
return i0.RawValuesInsertable({ return i0.RawValuesInsertable({
if (assetId != null) 'asset_id': assetId, if (assetId != null) 'asset_id': assetId,
if (albumId != null) 'album_id': albumId, if (albumId != null) 'album_id': albumId,
if (marker_ != null) 'marker': marker_,
}); });
} }
i1.LocalAlbumAssetEntityCompanion copyWith({ i1.LocalAlbumAssetEntityCompanion copyWith({
i0.Value<String>? assetId, i0.Value<String>? assetId,
i0.Value<String>? albumId, i0.Value<String>? albumId,
i0.Value<bool?>? marker_,
}) { }) {
return i1.LocalAlbumAssetEntityCompanion( return i1.LocalAlbumAssetEntityCompanion(
assetId: assetId ?? this.assetId, assetId: assetId ?? this.assetId,
albumId: albumId ?? this.albumId, albumId: albumId ?? this.albumId,
marker_: marker_ ?? this.marker_,
); );
} }
@@ -651,6 +717,9 @@ class LocalAlbumAssetEntityCompanion
if (albumId.present) { if (albumId.present) {
map['album_id'] = i0.Variable<String>(albumId.value); map['album_id'] = i0.Variable<String>(albumId.value);
} }
if (marker_.present) {
map['marker'] = i0.Variable<bool>(marker_.value);
}
return map; return map;
} }
@@ -658,7 +727,8 @@ class LocalAlbumAssetEntityCompanion
String toString() { String toString() {
return (StringBuffer('LocalAlbumAssetEntityCompanion(') return (StringBuffer('LocalAlbumAssetEntityCompanion(')
..write('assetId: $assetId, ') ..write('assetId: $assetId, ')
..write('albumId: $albumId') ..write('albumId: $albumId, ')
..write('marker_: $marker_')
..write(')')) ..write(')'))
.toString(); .toString();
} }

View File

@@ -93,7 +93,7 @@ class Drift extends $Drift implements IDatabaseRepository {
} }
@override @override
int get schemaVersion => 10; int get schemaVersion => 11;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -156,6 +156,9 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor); await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
await m.alterTable(TableMigration(v10.userEntity)); await m.alterTable(TableMigration(v10.userEntity));
}, },
from10To11: (m, v11) async {
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
},
), ),
); );

View File

@@ -4270,6 +4270,395 @@ i1.GeneratedColumn<String> _column_94(String aliasedName) =>
true, true,
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string,
); );
final class Schema11 extends i0.VersionedSchema {
Schema11({required super.database}) : super(version: 11);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
idxLatLng,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape17 remoteAssetEntity = Shape17(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape2 localAssetEntity = Shape2(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
}
class Shape22 extends i0.VersionedTable {
Shape22({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get albumId =>
columnsByName['album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get marker_ =>
columnsByName['marker']! as i1.GeneratedColumn<bool>;
}
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -4280,6 +4669,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8, required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9, required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10, required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -4328,6 +4718,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from9To10(migrator, schema); await from9To10(migrator, schema);
return 10; return 10;
case 10:
final schema = Schema11(database: database);
final migrator = i1.Migrator(database, schema);
await from10To11(migrator, schema);
return 11;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -4344,6 +4739,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8, required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9, required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10, required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -4355,5 +4751,6 @@ i1.OnUpgrade stepByStep({
from7To8: from7To8, from7To8: from7To8,
from8To9: from8To9, from8To9: from8To9,
from9To10: from9To10, from9To10: from9To10,
from10To11: from10To11,
), ),
); );

View File

@@ -72,17 +72,33 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return Future.value(); return Future.value();
} }
final deleteSmt = _db.localAssetEntity.delete(); return _db.transaction(() async {
deleteSmt.where((localAsset) { await _db.managers.localAlbumAssetEntity
final subQuery = _db.localAlbumAssetEntity.selectOnly() .filter((row) => row.albumId.id.equals(albumId))
..addColumns([_db.localAlbumAssetEntity.assetId]) .update((album) => album(marker_: const Value(true)));
..join([innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id))]);
subQuery.where( await _db.batch((batch) {
_db.localAlbumEntity.id.equals(albumId) & _db.localAlbumAssetEntity.assetId.isNotIn(assetIdsToKeep), for (final assetId in assetIdsToKeep) {
); batch.update(
return localAsset.id.isInQuery(subQuery); _db.localAlbumAssetEntity,
const LocalAlbumAssetEntityCompanion(marker_: Value(null)),
where: (row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
);
}
});
final query = _db.localAssetEntity.delete()
..where(
(row) => row.id.isInQuery(
_db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(
_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAlbumAssetEntity.marker_.isNotNull(),
),
),
);
await query.go();
}); });
await deleteSmt.go();
} }
Future<void> upsert( Future<void> upsert(
@@ -198,10 +214,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
// List<String> // List<String>
await _db.batch((batch) async { await _db.batch((batch) async {
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) { assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
batch.deleteWhere( for (final albumId in albumIds.cast<String?>().nonNulls) {
_db.localAlbumAssetEntity, batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId));
(f) => f.albumId.isNotIn(albumIds.cast<String?>().nonNulls) & f.assetId.equals(assetId), }
);
}); });
}); });
await _db.batch((batch) async { await _db.batch((batch) async {
@@ -288,12 +303,14 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return transaction(() async { return transaction(() async {
if (assetsToUnLink.isNotEmpty) { if (assetsToUnLink.isNotEmpty) {
await _db.batch( await _db.batch((batch) {
(batch) => batch.deleteWhere( for (final assetId in assetsToUnLink) {
_db.localAlbumAssetEntity, batch.deleteWhere(
(f) => f.assetId.isIn(assetsToUnLink) & f.albumId.equals(albumId), _db.localAlbumAssetEntity,
), (row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
); );
}
});
} }
await _deleteAssets(assetsToDelete); await _deleteAssets(assetsToDelete);
@@ -320,7 +337,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
} }
return _db.batch((batch) { return _db.batch((batch) {
batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids)); for (final id in ids) {
batch.deleteWhere(_db.localAssetEntity, (row) => row.id.equals(id));
}
}); });
} }

View File

@@ -1,4 +1,3 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -58,8 +57,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
} }
return _db.batch((batch) { return _db.batch((batch) {
for (final slice in ids.slices(32000)) { for (final id in ids) {
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.isIn(slice)); batch.deleteWhere(_db.localAssetEntity, (e) => e.id.equals(id));
} }
}); });
} }

View File

@@ -166,8 +166,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
); );
} }
Future<int> removeAssets(String albumId, List<String> assetIds) { Future<void> removeAssets(String albumId, List<String> assetIds) {
return _db.remoteAlbumAssetEntity.deleteWhere((tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds)); return _db.batch((batch) {
for (final assetId in assetIds) {
batch.deleteWhere(
_db.remoteAlbumAssetEntity,
(row) => row.albumId.equals(albumId) & row.assetId.equals(assetId),
);
}
});
} }
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) { FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {

View File

@@ -160,7 +160,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
} }
Future<void> delete(List<String> ids) { Future<void> delete(List<String> ids) {
return _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(ids)); return _db.batch((batch) {
for (final id in ids) {
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(id));
}
});
} }
Future<void> updateLocation(List<String> ids, LatLng location) { Future<void> updateLocation(List<String> ids, LatLng location) {
@@ -199,7 +203,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
.map((row) => row.id) .map((row) => row.id)
.get(); .get();
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds)); await _db.batch((batch) {
for (final stackId in stackIds) {
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
}
});
await _db.batch((batch) { await _db.batch((batch) {
final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId)); final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId));
@@ -219,15 +227,21 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
Future<void> unStack(List<String> stackIds) { Future<void> unStack(List<String> stackIds) {
return _db.transaction(() async { return _db.transaction(() async {
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds)); await _db.batch((batch) {
for (final stackId in stackIds) {
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
}
});
// TODO: delete this after adding foreign key on stackId // TODO: delete this after adding foreign key on stackId
await _db.batch((batch) { await _db.batch((batch) {
batch.update( for (final stackId in stackIds) {
_db.remoteAssetEntity, batch.update(
const RemoteAssetEntityCompanion(stackId: Value(null)), _db.remoteAssetEntity,
where: (e) => e.stackId.isIn(stackIds), const RemoteAssetEntityCompanion(stackId: Value(null)),
); where: (e) => e.stackId.equals(stackId),
);
}
}); });
}); });
} }

View File

@@ -33,7 +33,7 @@ class SearchApiRepository extends ApiRepository {
personIds: filter.people.map((e) => e.id).toList(), personIds: filter.people.map((e) => e.id).toList(),
type: type, type: type,
page: page, page: page,
size: 1000, size: 100,
), ),
); );
} }

View File

@@ -93,7 +93,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async { Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
try { try {
await _db.userEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.userId))); await _db.batch((batch) {
for (final user in data) {
batch.deleteWhere(_db.userEntity, (row) => row.id.equals(user.userId));
}
});
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Error: SyncUserDeleteV1', error, stack); _logger.severe('Error: SyncUserDeleteV1', error, stack);
rethrow; rethrow;
@@ -158,7 +162,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data, {String debugLabel = 'user'}) async { Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data, {String debugLabel = 'user'}) async {
try { try {
await _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId))); await _db.batch((batch) {
for (final asset in data) {
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(asset.assetId));
}
});
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack); _logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
rethrow; rethrow;
@@ -243,7 +251,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async { Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
try { try {
await _db.remoteAlbumEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId))); await _db.batch((batch) {
for (final album in data) {
batch.deleteWhere(_db.remoteAlbumEntity, (row) => row.id.equals(album.albumId));
}
});
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Error: deleteAlbumsV1', error, stack); _logger.severe('Error: deleteAlbumsV1', error, stack);
rethrow; rethrow;
@@ -379,7 +391,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async { Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async {
try { try {
await _db.memoryEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.memoryId))); await _db.batch((batch) {
for (final memory in data) {
batch.deleteWhere(_db.memoryEntity, (row) => row.id.equals(memory.memoryId));
}
});
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Error: deleteMemoriesV1', error, stack); _logger.severe('Error: deleteMemoriesV1', error, stack);
rethrow; rethrow;
@@ -443,7 +459,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteStacksV1(Iterable<SyncStackDeleteV1> data, {String debugLabel = 'user'}) async { Future<void> deleteStacksV1(Iterable<SyncStackDeleteV1> data, {String debugLabel = 'user'}) async {
try { try {
await _db.stackEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.stackId))); await _db.batch((batch) {
for (final stack in data) {
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stack.stackId));
}
});
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack); _logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
rethrow; rethrow;

View File

@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.w
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
@@ -33,7 +34,14 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
return; return;
} }
ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); WidgetsBinding.instance.addPostFrameCallback((_) async {
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
await ref.read(backgroundSyncProvider).syncRemote();
if (mounted) {
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
}
});
} }
@override @override
@@ -44,7 +52,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
.toList(); .toList();
final backupNotifier = ref.read(driftBackupProvider.notifier); final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundManager = ref.read(backgroundSyncProvider);
Future<void> startBackup() async { Future<void> startBackup() async {
final currentUser = Store.tryGet(StoreKey.currentUser); final currentUser = Store.tryGet(StoreKey.currentUser);
@@ -52,7 +59,6 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
return; return;
} }
await backgroundManager.syncRemote();
await backupNotifier.getBackupStatus(currentUser.id); await backupNotifier.getBackupStatus(currentUser.id);
await backupNotifier.startBackup(currentUser.id); await backupNotifier.startBackup(currentUser.id);
} }
@@ -235,11 +241,13 @@ class _BackupCard extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final backupCount = ref.watch(driftBackupProvider.select((p) => p.backupCount)); final backupCount = ref.watch(driftBackupProvider.select((p) => p.backupCount));
final syncStatus = ref.watch(syncStatusProvider);
return BackupInfoCard( return BackupInfoCard(
title: "backup_controller_page_backup".tr(), title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(), subtitle: "backup_controller_page_backup_sub".tr(),
info: backupCount.toString(), info: backupCount.toString(),
isLoading: syncStatus.isRemoteSyncing,
); );
} }
} }
@@ -250,10 +258,13 @@ class _RemainderCard extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount)); final remainderCount = ref.watch(driftBackupProvider.select((p) => p.remainderCount));
final syncStatus = ref.watch(syncStatusProvider);
return BackupInfoCard( return BackupInfoCard(
title: "backup_controller_page_remainder".tr(), title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(), subtitle: "backup_controller_page_remainder_sub".tr(),
info: remainderCount.toString(), info: remainderCount.toString(),
isLoading: syncStatus.isRemoteSyncing,
onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()), onTap: () => context.pushRoute(const DriftBackupAssetDetailRoute()),
); );
} }

View File

@@ -0,0 +1,57 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/download_panel.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
@RoutePage()
class DownloadInfoPage extends ConsumerWidget {
const DownloadInfoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList();
onCancelDownload(String id) {
ref.watch(downloadStateProvider.notifier).cancelDownload(id);
}
return Scaffold(
appBar: AppBar(
title: Text("download".t(context: context)),
actions: [],
),
body: ListView.builder(
physics: const ClampingScrollPhysics(),
shrinkWrap: true,
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: DownloadTaskTile(
progress: task.value.progress,
fileName: task.value.fileName,
status: task.value.status,
onCancelDownload: () => onCancelDownload(task.key),
),
);
},
),
persistentFooterButtons: [
OutlinedButton(
onPressed: () {
tasks.map((e) => e.key).forEach(onCancelDownload);
},
style: OutlinedButton.styleFrom(side: BorderSide(color: context.colorScheme.primary)),
child: Text(
'clear_all'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
),
),
],
);
}
}

View File

@@ -633,7 +633,7 @@ class _SearchResultGrid extends ConsumerWidget {
groupBy: GroupAssetsBy.none, groupBy: GroupAssetsBy.none,
appBar: null, appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
withScrubber: false, snapToMonth: false,
), ),
), ),
), ),

View File

@@ -1,54 +1,45 @@
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class DownloadActionButton extends ConsumerWidget { class DownloadActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool menuItem;
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
const DownloadActionButton({super.key, required this.source}); void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
final result = await ref.read(actionProvider.notifier).downloadAll(source); try {
ref.read(multiSelectProvider.notifier).reset(); await ref.read(actionProvider.notifier).downloadAll(source);
if (!context.mounted) { Future.delayed(const Duration(seconds: 1), () async {
return; await backgroundSyncManager.syncLocal();
} await backgroundSyncManager.hashAssets();
});
if (!result.success) { } finally {
ImmichToast.show( ref.read(multiSelectProvider.notifier).reset();
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
} else if (result.count > 0) {
ImmichToast.show(
context: context,
msg: 'download_action_prompt'.t(context: context, args: {'count': result.count.toString()}),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.success,
);
} }
} }
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final backgroundManager = ref.watch(backgroundSyncProvider);
return BaseActionButton( return BaseActionButton(
iconData: Icons.download, iconData: Icons.download,
maxWidth: 95, maxWidth: 95,
label: "download".t(context: context), label: "download".t(context: context),
onPressed: () => _onTap(context, ref), menuItem: menuItem,
onPressed: () => _onTap(context, ref, backgroundManager),
); );
} }
} }

View File

@@ -0,0 +1,64 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class DownloadStatusFloatingButton extends ConsumerWidget {
const DownloadStatusFloatingButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final shouldShow = ref.watch(downloadStateProvider.select((state) => state.showProgress));
final itemCount = ref.watch(downloadStateProvider.select((state) => state.taskProgress.length));
final isDownloading = ref
.watch(downloadStateProvider.select((state) => state.taskProgress))
.values
.where((element) => element.progress != 1)
.isNotEmpty;
return shouldShow
? Badge.count(
count: itemCount,
textColor: context.colorScheme.onPrimary,
backgroundColor: context.colorScheme.primary,
child: FloatingActionButton(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
),
backgroundColor: context.isDarkTheme
? context.colorScheme.surfaceContainer
: context.colorScheme.surfaceBright,
elevation: 2,
onPressed: () {
context.pushRoute(const DownloadInfoRoute());
},
child: Stack(
alignment: AlignmentDirectional.center,
children: [
isDownloading
? Icon(Icons.downloading_rounded, color: context.colorScheme.primary, size: 28)
: Icon(
Icons.download_done,
color: context.isDarkTheme ? Colors.green[200] : Colors.green[400],
size: 28,
),
if (isDownloading)
const SizedBox(
height: 31,
width: 31,
child: CircularProgressIndicator(
strokeWidth: 2,
backgroundColor: Colors.transparent,
value: null, // Indeterminate progress
),
),
],
),
),
)
: const SizedBox.shrink();
}
}

View File

@@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -649,20 +650,25 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
appBar: const ViewerTopAppBar(), appBar: const ViewerTopAppBar(),
extendBody: true, extendBody: true,
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
body: PhotoViewGallery.builder( floatingActionButton: const DownloadStatusFloatingButton(),
gaplessPlayback: true, body: Stack(
loadingBuilder: _placeholderBuilder, children: [
pageController: pageController, PhotoViewGallery.builder(
scrollPhysics: CurrentPlatform.isIOS gaplessPlayback: true,
? const FastScrollPhysics() // Use bouncing physics for iOS loadingBuilder: _placeholderBuilder,
: const FastClampingScrollPhysics(), // Use heavy physics for Android pageController: pageController,
itemCount: totalAssets, scrollPhysics: CurrentPlatform.isIOS
onPageChanged: _onPageChanged, ? const FastScrollPhysics() // Use bouncing physics for iOS
onPageBuild: _onPageBuild, : const FastClampingScrollPhysics(), // Use heavy physics for Android
scaleStateChangedCallback: _onScaleStateChanged, itemCount: totalAssets,
builder: _assetBuilder, onPageChanged: _onPageChanged,
backgroundDecoration: BoxDecoration(color: backgroundColor), onPageBuild: _onPageBuild,
enablePanAlways: true, scaleStateChangedCallback: _onScaleStateChanged,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
],
), ),
bottomNavigationBar: showingBottomSheet bottomNavigationBar: showingBottomSheet
? const SizedBox.shrink() ? const SizedBox.shrink()

View File

@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
@@ -56,6 +57,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final actions = <Widget>[ final actions = <Widget>[
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true), if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared) if (album != null && album.isActivityEnabled && album.isShared)
IconButton( IconButton(

View File

@@ -16,8 +16,9 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
final String id; final String id;
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
final bool exact;
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution}); LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution, this.exact = true});
@override @override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) { Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -37,7 +38,12 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
} }
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) { Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType); final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final request = this.request = LocalImageRequest(
localId: key.id,
size: key.size * devicePixelRatio,
assetType: key.assetType,
);
return loadRequest(request, decode); return loadRequest(request, decode);
} }
@@ -45,7 +51,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is LocalThumbProvider) { if (other is LocalThumbProvider) {
return id == other.id; return id == other.id && (!exact || size == other.size);
} }
return false; return false;
} }
@@ -60,7 +66,12 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
LocalFullImageProvider({required this.id, required this.assetType, required this.size}); LocalFullImageProvider({
required this.id,
required this.assetType,
required this.size,
LocalThumbProvider? initialProvider,
});
@override @override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -71,7 +82,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter( return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)), initialImage: getInitialImage(LocalThumbProvider(id: id, assetType: assetType, exact: false)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),

View File

@@ -2,7 +2,7 @@ import 'dart:ui';
const double kTimelineHeaderExtent = 80.0; const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256); const Size kTimelineFixedTileExtent = Size.square(256);
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size const Size kThumbnailResolution = Size.square(128);
const double kTimelineSpacing = 2.0; const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3; const int kTimelineColumnCount = 3;

View File

@@ -101,7 +101,6 @@ class _FixedSegmentRow extends ConsumerWidget {
if (isScrubbing) { if (isScrubbing) {
return _buildPlaceholder(context); return _buildPlaceholder(context);
} }
if (timelineService.hasRange(assetIndex, assetCount)) { if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService); return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
} }
@@ -122,6 +121,7 @@ class _FixedSegmentRow extends ConsumerWidget {
} }
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) { Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
final size = Size.square(tileHeight);
return FixedTimelineRow( return FixedTimelineRow(
dimension: tileHeight, dimension: tileHeight,
spacing: spacing, spacing: spacing,
@@ -135,6 +135,7 @@ class _FixedSegmentRow extends ConsumerWidget {
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i], asset: assets[i],
assetIndex: assetIndex + i, assetIndex: assetIndex + i,
size: size,
), ),
), ),
], ],
@@ -145,8 +146,9 @@ class _FixedSegmentRow extends ConsumerWidget {
class _AssetTileWidget extends ConsumerWidget { class _AssetTileWidget extends ConsumerWidget {
final BaseAsset asset; final BaseAsset asset;
final int assetIndex; final int assetIndex;
final Size size;
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex}); const _AssetTileWidget({super.key, required this.asset, required this.assetIndex, required this.size});
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async { Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
final multiSelectState = ref.read(multiSelectProvider); final multiSelectState = ref.read(multiSelectProvider);
@@ -204,6 +206,7 @@ class _AssetTileWidget extends ConsumerWidget {
lockSelection: lockSelection, lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset, heroOffset: heroOffset,
size: size,
), ),
), ),
); );

View File

@@ -11,6 +11,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:intl/intl.dart' hide TextDirection; import 'package:intl/intl.dart' hide TextDirection;
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged /// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
@@ -30,6 +31,11 @@ class Scrubber extends ConsumerStatefulWidget {
final double? monthSegmentSnappingOffset; final double? monthSegmentSnappingOffset;
final bool snapToMonth;
/// Whether an app bar is present, affects coordinate calculations
final bool hasAppBar;
Scrubber({ Scrubber({
super.key, super.key,
Key? scrollThumbKey, Key? scrollThumbKey,
@@ -38,6 +44,8 @@ class Scrubber extends ConsumerStatefulWidget {
this.topPadding = 0, this.topPadding = 0,
this.bottomPadding = 0, this.bottomPadding = 0,
this.monthSegmentSnappingOffset, this.monthSegmentSnappingOffset,
this.snapToMonth = true,
this.hasAppBar = true,
required this.child, required this.child,
}) : assert(child.scrollDirection == Axis.vertical); }) : assert(child.scrollDirection == Axis.vertical);
@@ -81,6 +89,8 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
bool _isDragging = false; bool _isDragging = false;
List<_Segment> _segments = []; List<_Segment> _segments = [];
int _monthCount = 0; int _monthCount = 0;
DateTime? _currentScrubberDate;
Debouncer? _scrubberDebouncer;
late AnimationController _thumbAnimationController; late AnimationController _thumbAnimationController;
Timer? _fadeOutTimer; Timer? _fadeOutTimer;
@@ -133,6 +143,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
_thumbAnimationController.dispose(); _thumbAnimationController.dispose();
_labelAnimationController.dispose(); _labelAnimationController.dispose();
_fadeOutTimer?.cancel(); _fadeOutTimer?.cancel();
_scrubberDebouncer?.dispose();
super.dispose(); super.dispose();
} }
@@ -176,11 +187,25 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
return false; return false;
} }
void _onDragStart(DragStartDetails _) { void _onScrubberDateChanged(DateTime date) {
if (_monthCount >= kMinMonthsToEnableScrubberSnap) { if (_currentScrubberDate != date) {
// Date changed, immediately set scrubbing to true
_currentScrubberDate = date;
ref.read(timelineStateProvider.notifier).setScrubbing(true); ref.read(timelineStateProvider.notifier).setScrubbing(true);
}
// Initialize debouncer if needed
_scrubberDebouncer ??= Debouncer(interval: const Duration(milliseconds: 50));
// Debounce setting scrubbing to false
_scrubberDebouncer!.run(() {
if (_currentScrubberDate == date) {
ref.read(timelineStateProvider.notifier).setScrubbing(false);
}
});
}
}
void _onDragStart(DragStartDetails _) {
setState(() { setState(() {
_isDragging = true; _isDragging = true;
_labelAnimationController.forward(); _labelAnimationController.forward();
@@ -206,10 +231,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
if (_lastLabel != label) { if (_lastLabel != label) {
ref.read(hapticFeedbackProvider.notifier).selectionClick(); ref.read(hapticFeedbackProvider.notifier).selectionClick();
_lastLabel = label; _lastLabel = label;
// Notify timeline state of the new scrubber date position
if (_monthCount >= kMinMonthsToEnableScrubberSnap) {
_onScrubberDateChanged(nearestMonthSegment.date);
}
} }
} }
if (_monthCount < kMinMonthsToEnableScrubberSnap) { if (_monthCount < kMinMonthsToEnableScrubberSnap || !widget.snapToMonth) {
// If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments // If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments
setState(() { setState(() {
_thumbTopOffset = dragPosition; _thumbTopOffset = dragPosition;
@@ -236,14 +266,28 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
/// - If user drags to global Y position that's 100 pixels from the top /// - If user drags to global Y position that's 100 pixels from the top
/// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area) /// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area)
double _calculateDragPosition(DragUpdateDetails details) { double _calculateDragPosition(DragUpdateDetails details) {
if (widget.hasAppBar) {
final dragAreaTop = widget.topPadding;
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
final dragAreaHeight = dragAreaBottom - dragAreaTop;
final relativePosition = details.globalPosition.dy - dragAreaTop;
// Make sure the position stays within the scrubber's bounds
return relativePosition.clamp(0.0, dragAreaHeight);
}
// Get the local position relative to the gesture detector
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null) {
final localPosition = renderBox.globalToLocal(details.globalPosition);
return localPosition.dy.clamp(0.0, _scrubberHeight);
}
// Fallback to current logic if render box is not available
final dragAreaTop = widget.topPadding; final dragAreaTop = widget.topPadding;
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
final dragAreaHeight = dragAreaBottom - dragAreaTop;
final relativePosition = details.globalPosition.dy - dragAreaTop; final relativePosition = details.globalPosition.dy - dragAreaTop;
return relativePosition.clamp(0.0, _scrubberHeight);
// Make sure the position stays within the scrubber's bounds
return relativePosition.clamp(0.0, dragAreaHeight);
} }
/// Find the segment closest to the given position /// Find the segment closest to the given position
@@ -294,12 +338,18 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
} }
void _onDragEnd(DragEndDetails _) { void _onDragEnd(DragEndDetails _) {
ref.read(timelineStateProvider.notifier).setScrubbing(false);
_labelAnimationController.reverse(); _labelAnimationController.reverse();
setState(() { setState(() {
_isDragging = false; _isDragging = false;
}); });
ref.read(timelineStateProvider.notifier).setScrubbing(false);
// Reset scrubber tracking when drag ends
_currentScrubberDate = null;
_scrubberDebouncer?.dispose();
_scrubberDebouncer = null;
_resetThumbTimer(); _resetThumbTimer();
} }

View File

@@ -72,8 +72,6 @@ class TimelineState {
} }
class TimelineStateNotifier extends Notifier<TimelineState> { class TimelineStateNotifier extends Notifier<TimelineState> {
TimelineStateNotifier();
void setScrubbing(bool isScrubbing) { void setScrubbing(bool isScrubbing) {
state = state.copyWith(isScrubbing: isScrubbing); state = state.copyWith(isScrubbing: isScrubbing);
} }

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
@@ -38,6 +39,7 @@ class Timeline extends StatelessWidget {
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18), this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18),
this.groupBy, this.groupBy,
this.withScrubber = true, this.withScrubber = true,
this.snapToMonth = true,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
@@ -48,11 +50,13 @@ class Timeline extends StatelessWidget {
final bool withStack; final bool withStack;
final GroupAssetsBy? groupBy; final GroupAssetsBy? groupBy;
final bool withScrubber; final bool withScrubber;
final bool snapToMonth;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(),
body: LayoutBuilder( body: LayoutBuilder(
builder: (_, constraints) => ProviderScope( builder: (_, constraints) => ProviderScope(
overrides: [ overrides: [
@@ -73,6 +77,7 @@ class Timeline extends StatelessWidget {
appBar: appBar, appBar: appBar,
bottomSheet: bottomSheet, bottomSheet: bottomSheet,
withScrubber: withScrubber, withScrubber: withScrubber,
snapToMonth: snapToMonth,
), ),
), ),
), ),
@@ -87,6 +92,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
this.appBar, this.appBar,
this.bottomSheet, this.bottomSheet,
this.withScrubber = true, this.withScrubber = true,
this.snapToMonth = true,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
@@ -94,6 +100,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withScrubber; final bool withScrubber;
final bool snapToMonth;
@override @override
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
@@ -309,11 +316,13 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final Widget timeline; final Widget timeline;
if (widget.withScrubber) { if (widget.withScrubber) {
timeline = Scrubber( timeline = Scrubber(
snapToMonth: widget.snapToMonth,
layoutSegments: segments, layoutSegments: segments,
timelineHeight: maxHeight, timelineHeight: maxHeight,
topPadding: topPadding, topPadding: topPadding,
bottomPadding: bottomPadding, bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null,
child: grid, child: grid,
); );
} else { } else {

View File

@@ -235,7 +235,9 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
switch (update.status) { switch (update.status) {
case TaskStatus.complete: case TaskStatus.complete:
if (update.task.group == kBackupGroup) { if (update.task.group == kBackupGroup) {
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1); if (update.responseStatusCode == 201) {
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
}
} }
// Remove the completed task from the upload items // Remove the completed task from the upload items

View File

@@ -356,7 +356,6 @@ class ActionNotifier extends Notifier<void> {
Future<ActionResult> downloadAll(ActionSource source) async { Future<ActionResult> downloadAll(ActionSource source) async {
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false); final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
try { try {
final didEnqueue = await _service.downloadAll(assets); final didEnqueue = await _service.downloadAll(assets);
final enqueueCount = didEnqueue.where((e) => e).length; final enqueueCount = didEnqueue.where((e) => e).length;

View File

@@ -90,7 +90,11 @@ class DownloadRepository {
final isVideo = asset.isVideo; final isVideo = asset.isVideo;
final url = getOriginalUrlForRemoteId(id); final url = getOriginalUrlForRemoteId(id);
if (Platform.isAndroid || livePhotoVideoId == null || isVideo) { // on iOS it cannot link the image, check if the filename has .MP extension
// to avoid downloading the video part
final isAndroidMotionPhoto = asset.name.contains(".MP");
if (Platform.isAndroid || livePhotoVideoId == null || isVideo || isAndroidMotionPhoto) {
tasks[taskIndex++] = DownloadTask( tasks[taskIndex++] = DownloadTask(
taskId: id, taskId: id,
url: url, url: url,

View File

@@ -81,6 +81,7 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart'; import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
@@ -345,6 +346,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart // required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722 // auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'), RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -688,6 +688,22 @@ class CropImageRouteArgs {
} }
} }
/// generated route for
/// [DownloadInfoPage]
class DownloadInfoRoute extends PageRouteInfo<void> {
const DownloadInfoRoute({List<PageRouteInfo>? children})
: super(DownloadInfoRoute.name, initialChildren: children);
static const String name = 'DownloadInfoRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DownloadInfoPage();
},
);
}
/// generated route for /// generated route for
/// [DriftActivitiesPage] /// [DriftActivitiesPage]
class DriftActivitiesRoute extends PageRouteInfo<void> { class DriftActivitiesRoute extends PageRouteInfo<void> {

View File

@@ -1,7 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -11,6 +10,7 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart';
@@ -199,14 +199,11 @@ class ActionService {
} }
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async { Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
int removedCount = 0;
final result = await _albumApiRepository.removeAssets(albumId, remoteIds); final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
if (result.removed.isNotEmpty) { if (result.removed.isNotEmpty) {
removedCount = await _remoteAlbumRepository.removeAssets(albumId, result.removed); await _remoteAlbumRepository.removeAssets(albumId, result.removed);
} }
return result.removed.length;
return removedCount;
} }
Future<bool> updateDescription(String assetId, String description) async { Future<bool> updateDescription(String assetId, String description) async {

View File

@@ -8,8 +8,17 @@ class BackupInfoCard extends StatelessWidget {
final String title; final String title;
final String subtitle; final String subtitle;
final String info; final String info;
final VoidCallback? onTap; final VoidCallback? onTap;
const BackupInfoCard({super.key, required this.title, required this.subtitle, required this.info, this.onTap}); final bool isLoading;
const BackupInfoCard({
super.key,
required this.title,
required this.subtitle,
required this.info,
this.onTap,
this.isLoading = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -38,8 +47,36 @@ class BackupInfoCard extends StatelessWidget {
trailing: Column( trailing: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text(info, style: context.textTheme.titleLarge), Stack(
Text("backup_info_card_assets", style: context.textTheme.labelLarge).tr(), children: [
Text(
info,
style: context.textTheme.titleLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(isLoading ? 50 : 255),
),
),
if (isLoading)
Positioned.fill(
child: Align(
alignment: Alignment.center,
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colorScheme.onSurface.withAlpha(150),
),
),
),
),
],
),
Text(
"backup_info_card_assets",
style: context.textTheme.labelLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(isLoading ? 50 : 255),
),
).tr(),
], ],
), ),
), ),

View File

@@ -13,6 +13,7 @@ import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8; import 'schema_v8.dart' as v8;
import 'schema_v9.dart' as v9; import 'schema_v9.dart' as v9;
import 'schema_v10.dart' as v10; import 'schema_v10.dart' as v10;
import 'schema_v11.dart' as v11;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -38,10 +39,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v9.DatabaseAtV9(db); return v9.DatabaseAtV9(db);
case 10: case 10:
return v10.DatabaseAtV10(db); return v10.DatabaseAtV10(db);
case 11:
return v11.DatabaseAtV11(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
} }

File diff suppressed because it is too large Load Diff

View File

@@ -57,28 +57,28 @@ export class MediaRepository {
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input); const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
return { buffer, format: RawExtractedFormat.Jpeg }; return { buffer, format: RawExtractedFormat.Jpeg };
} catch (error: any) { } catch (error: any) {
this.logger.debug('Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next', error.message); this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`);
} }
try { try {
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input); const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
return { buffer, format: RawExtractedFormat.Jpeg }; return { buffer, format: RawExtractedFormat.Jpeg };
} catch (error: any) { } catch (error: any) {
this.logger.debug('Could not extract JPEG buffer from image, trying PreviewJXL next', error.message); this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`);
} }
try { try {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input); const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
return { buffer, format: RawExtractedFormat.Jxl }; return { buffer, format: RawExtractedFormat.Jxl };
} catch (error: any) { } catch (error: any) {
this.logger.debug('Could not extract PreviewJXL buffer from image, trying PreviewImage next', error.message); this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`);
} }
try { try {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input); const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
return { buffer, format: RawExtractedFormat.Jpeg }; return { buffer, format: RawExtractedFormat.Jpeg };
} catch (error: any) { } catch (error: any) {
this.logger.debug('Could not extract preview buffer from image', error.message); this.logger.debug(`Could not extract preview buffer from image: ${error}`);
return null; return null;
} }
} }

View File

@@ -103,7 +103,7 @@ export class MetadataRepository {
readTags(path: string): Promise<ImmichTags> { readTags(path: string): Promise<ImmichTags> {
return this.exiftool.read(path).catch((error) => { return this.exiftool.read(path).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack); this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
return {}; return {};
}) as Promise<ImmichTags>; }) as Promise<ImmichTags>;
} }

View File

@@ -344,7 +344,7 @@ export class AuthService extends BaseService {
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } }); await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } });
} }
} catch (error: Error | any) { } catch (error: Error | any) {
this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack); this.logger.warn(`Unable to sync oauth profile picture: ${error}\n${error?.stack}`);
} }
} }

View File

@@ -132,12 +132,12 @@ export class BackupService extends BaseService {
gzip.stdout.pipe(fileStream); gzip.stdout.pipe(fileStream);
pgdump.on('error', (err) => { pgdump.on('error', (err) => {
this.logger.error('Backup failed with error', err); this.logger.error(`Backup failed with error: ${err}`);
reject(err); reject(err);
}); });
gzip.on('error', (err) => { gzip.on('error', (err) => {
this.logger.error('Gzip failed with error', err); this.logger.error(`Gzip failed with error: ${err}`);
reject(err); reject(err);
}); });
@@ -175,10 +175,10 @@ export class BackupService extends BaseService {
}); });
await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', '')); await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
} catch (error) { } catch (error) {
this.logger.error('Database Backup Failure', error); this.logger.error(`Database Backup Failure: ${error}`);
await this.storageRepository await this.storageRepository
.unlink(backupFilePath) .unlink(backupFilePath)
.catch((error) => this.logger.error('Failed to delete failed backup file', error)); .catch((error) => this.logger.error(`Failed to delete failed backup file: ${error}`));
throw error; throw error;
} }

View File

@@ -245,7 +245,7 @@ export class LibraryService extends BaseService {
job.paths.map((path) => job.paths.map((path) =>
this.processEntity(path, library.ownerId, job.libraryId) this.processEntity(path, library.ownerId, job.libraryId)
.then((asset) => assetImports.push(asset)) .then((asset) => assetImports.push(asset))
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}`, error)), .catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`)),
), ),
); );

View File

@@ -40,7 +40,7 @@ export class MemoryService extends BaseService {
try { try {
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target))); await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
} catch (error) { } catch (error) {
this.logger.error(`Failed to create memories for ${target.toISO()}`, error); this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`);
} }
// update system metadata even when there is an error to minimize the chance of duplicates // update system metadata even when there is an error to minimize the chance of duplicates
await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, { await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, {

View File

@@ -338,7 +338,7 @@ export class StorageTemplateService extends BaseService {
return destination; return destination;
} catch (error: any) { } catch (error: any) {
this.logger.error(`Unable to get template path for ${filename}`, error); this.logger.error(`Unable to get template path for ${filename}: ${error}`);
return asset.originalPath; return asset.originalPath;
} }
} }

View File

@@ -95,7 +95,7 @@ export class VersionService extends BaseService {
this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata)); this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata));
} }
} catch (error: Error | any) { } catch (error: Error | any) {
this.logger.warn(`Unable to run version check: ${error}`, error?.stack); this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`);
return JobStatus.Failed; return JobStatus.Failed;
} }

View File

@@ -73,7 +73,7 @@ export const sendFile = async (
// log non-http errors // log non-http errors
if (error instanceof HttpException === false) { if (error instanceof HttpException === false) {
logger.error(`Unable to send file: ${error.name}`, error.stack); logger.error(`Unable to send file: ${error}`, error.stack);
} }
res.header('Cache-Control', 'none'); res.header('Cache-Control', 'none');

View File

@@ -240,7 +240,7 @@
use:zoomImageAction use:zoomImageAction
use:swipe={() => ({})} use:swipe={() => ({})}
onswipe={onSwipe} onswipe={onSwipe}
class="h-full w-full" class="h-full w-full flex"
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }} transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
> >
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
@@ -255,7 +255,7 @@
bind:this={$photoViewerImgElement} bind:this={$photoViewerImgElement}
src={assetFileUrl} src={assetFileUrl}
alt={$getAltText(toTimelineAsset(asset))} alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None class="max-h-full max-w-full h-auto w-auto mx-auto my-auto {$slideshowState === SlideshowState.None
? 'object-contain' ? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}" : slideshowLookCssMapping[$slideshowLook]}"
draggable="false" draggable="false"