Compare commits

..

24 Commits

Author SHA1 Message Date
Daniel Dietzler fd4d9c6897 feat: plugin wrapper type safety 2026-06-23 22:39:11 +02:00
Mees Frensel 06f3b4f259 refactor(web): simple actions (#29257) 2026-06-23 17:08:46 +02:00
Ben Beckford 99f94a363d chore(web): workflow property ordering (#29261)
* chore(web): workflow property ordering

* chore(web): extract schema property sorting to method
2026-06-23 13:03:33 +00:00
Daniel Dietzler c3092b1c2c chore: basque was missing on mobile (#29284) 2026-06-23 08:57:05 -04:00
renovate[bot] 0656e7e231 chore(deps): update dependency typescript to v6 (#28772)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-06-23 14:01:09 +02:00
renovate[bot] 1692b81b7c chore(deps): lock file maintenance (pub) (#28733) 2026-06-23 12:43:35 +02:00
renovate[bot] ff2028c4c8 chore(deps): update prom/prometheus docker digest to a75c5a3 (#29271) 2026-06-23 12:43:16 +02:00
Timon f22836e1bf refactor(server): describe check upload id as string (#29274) 2026-06-23 12:42:42 +02:00
renovate[bot] 7dd02ffbad chore(deps): update github-actions (#29272)
Co-authored-by: bo0tzz <git@bo0tzz.me>
2026-06-23 08:49:21 +00:00
shenlong e51c4cb355 feat: column button (#29265)
* refactor: icon buttons implicit loading

* chore: cleanup

* feat: ui color override

* feat: column button

* feat: ui menu item (#29266)

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-22 20:56:00 -05:00
shenlong d4102c0489 refactor: ui icon buttons implicit loading (#29263)
* refactor: icon buttons implicit loading

* chore: cleanup

* feat: ui color override (#29264)

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-22 16:24:52 -04:00
shenlong 30a73c1105 feat: mobile-ui snackbar (#29260)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-22 16:22:26 -04:00
Jason Rasmussen ec7c0f9ec8 fix: sync backfill (#29267) 2026-06-22 16:19:01 -04:00
Brandon Wees a5198e23a8 refactor: use SemVer classes for version compatability message (#29056)
* refactor: use SemVer classes for version compatability message

* chore: readd major version compatabilty messages

* fix: remove 1.106.0 check

(we dont support v1 servers anymore)
2026-06-22 11:28:56 -04:00
Mees Frensel 51f2905fcc fix(web): remove map's fullscreen button (#29192) 2026-06-22 16:58:07 +02:00
Vogeluff 3b7d75c18a feat(web): Add text-white-shadow to elements and increase the shadows effect (#29165)
* fix(web): increase text shadow strength for white text on thumbnails

* fix(web): fix class order for text-white-shadow

* fixup: format fix
2026-06-22 09:43:15 -05:00
Daniel Dietzler c484bd99b6 fix: ignore external libraries for integrity report checksum check (#29248) 2026-06-22 13:56:24 +00:00
Anthony Clerici c0bf5a4c56 fix(server): use VBR for QSV so the max bitrate is respected (#29240)
* fix(server): use VBR for QSV so the max bitrate is respected

* update test
2026-06-22 09:56:20 -04:00
MuySup d9d50d2848 fix: turkish readme translation (#29234)
* Translation completed

3-2-1 rule translated

* Fix formatting of warning message in Turkish README
2026-06-22 09:55:58 -04:00
Daniel Dietzler c7453a67fd fix: detail panel people reactivity and iterator consumption (#29250) 2026-06-22 15:47:09 +02:00
Daniel Dietzler e918e3a313 feat: keyboard seeking for new video player (#29208) 2026-06-22 09:42:59 -04:00
Matthew Momjian dc7d57ff9a fix(docsc): v3 bump (#29246)
v3 bump in docs
2026-06-21 20:44:58 -05:00
Alex b24a617142 chore: bump mobile build (#29215) 2026-06-19 12:50:20 -05:00
Mees Frensel 62b00a1f26 refactor: slideshow and setalbumcover actions (#29211)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-19 13:54:33 +00:00
88 changed files with 2017 additions and 1273 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'zulu'
java-version: '17'
+2 -1
View File
@@ -25,11 +25,12 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@3530478ec30f84adedbfeb28f0d9527a290f50a9 # v0.0.57
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
fail-on: ERR
review: false
check-mobile-patches:
runs-on: ubuntu-latest
+4 -4
View File
@@ -406,7 +406,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -483,7 +483,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -714,7 +714,7 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
- name: Install server dependencies
run: pnpm --filter immich install --frozen-lockfile
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- name: Run API generation
run: mise //:open-api
working-directory: open-api
@@ -772,7 +772,7 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
- name: Install server dependencies
run: pnpm install --frozen-lockfile
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build plugins
run: mise //:plugins
+1 -1
View File
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:69f5241418838263316593f7274a304b095c40bcf22e57272865da91bd60a8ac
image: prom/prometheus@sha256:a75c5a35bc21d7afe69551eefa3cb1e1fb1775fe759408007a66b54ec3de1f29
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
+1 -1
View File
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v2
IMMICH_VERSION=v3
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
+1 -1
View File
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-----: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
+1 -1
View File
@@ -29,7 +29,7 @@ docker image prune
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
+1 -1
View File
@@ -48,7 +48,7 @@
"pngjs": "^7.0.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.35.1",
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^6.0.0",
+1 -1
View File
@@ -1548,7 +1548,7 @@
"map_location_picker_page_use_location": "Use this location",
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
"map_location_service_disabled_title": "Location Service disabled",
"map_marker_for_images": "Map marker for images taken in {city}, {country}",
"map_marker_for_image": "Map marker for image taken in {city}, {country}",
"map_marker_with_image": "Map marker with image",
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
"map_no_location_permission_title": "Location Permission denied",
+1
View File
@@ -57,6 +57,7 @@ dir = "open-api"
run = "bash ./bin/generate-dart-sdk.sh"
[tasks.open-api]
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
run = [
{ task = "//:plugins" },
{ task = "//server:install" },
+2 -2
View File
@@ -22,7 +22,7 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3049,
"android.injected.version.code" => 3050,
"android.injected.version.name" => "3.0.0-rc.2",
}
)
@@ -35,7 +35,7 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3049,
"android.injected.version.code" => 3050,
"android.injected.version.name" => "3.0.0-rc.2",
}
)
+24 -35
View File
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@@ -11,6 +11,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
467DA6EAF83F3481F8BD94AB /* Pods_ShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -19,9 +20,9 @@
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
D3BED739C0BC29BB32E18EB2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC499FBCE6B29B2DAFED7130 /* Pods_Runner.framework */; };
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */; };
@@ -39,7 +40,6 @@
FEE084F82EC172460045228E /* SQLiteData in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084F72EC172460045228E /* SQLiteData */; };
FEE084FB2EC1725A0045228E /* RawStructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FA2EC1725A0045228E /* RawStructuredFieldValues */; };
FEE084FD2EC1725A0045228E /* StructuredFieldValues in Frameworks */ = {isa = PBXBuildFile; productRef = FEE084FC2EC1725A0045228E /* StructuredFieldValues */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -94,6 +94,7 @@
6D160F04A389B9FFBC557803 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
937632897A02DE9C249F20A6 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
@@ -109,9 +110,9 @@
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = "<group>"; };
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = "<group>"; };
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
C4A6A71F33CE37B3C913115C /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
CC499FBCE6B29B2DAFED7130 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -130,7 +131,6 @@
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteImagesImpl.swift; sourceTree = "<group>"; };
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = "<group>"; };
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -153,15 +153,11 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -183,8 +179,6 @@
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Schemas;
sourceTree = "<group>";
};
@@ -364,9 +358,6 @@
/* Begin PBXNativeTarget section */
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@@ -473,7 +464,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
);
@@ -528,10 +519,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -561,10 +556,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -758,7 +757,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = "";
SDKROOT = iphoneos;
@@ -777,7 +776,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -786,7 +785,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.121.0;
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile;
PRODUCT_NAME = "Immich-Profile";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -847,7 +846,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
NEW_SETTING = "";
ONLY_ACTIVE_ARCH = YES;
@@ -901,7 +900,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = "";
SDKROOT = iphoneos;
@@ -922,7 +921,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -931,7 +930,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.121.0;
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug;
PRODUCT_NAME = "Immich-Debug";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -951,7 +950,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -960,7 +959,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.121.0;
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
PRODUCT_NAME = Immich;
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1262,7 +1261,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
@@ -1307,17 +1306,7 @@
package = FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */;
productName = StructuredFieldValues;
};
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
+203 -202
View File
@@ -1,205 +1,206 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.background.refreshUpload</string>
<string>app.alextran.immich.background.processingUpload</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ShareHandler</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.file-url</string>
<string>public.image</string>
<string>public.text</string>
<string>public.movie</string>
<string>public.url</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>lv</string>
<string>mn</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>sl</string>
<string>sr</string>
<string>sv</string>
<string>th</string>
<string>uk</string>
<string>vi</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>3.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Share Extension</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>immich</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>240</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>
</plist>
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.background.refreshUpload</string>
<string>app.alextran.immich.background.processingUpload</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ShareHandler</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.file-url</string>
<string>public.image</string>
<string>public.text</string>
<string>public.movie</string>
<string>public.url</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>lv</string>
<string>mn</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>sl</string>
<string>sr</string>
<string>sv</string>
<string>th</string>
<string>uk</string>
<string>vi</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>3.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Share Extension</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>immich</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>4</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false />
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true />
<key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
</dict>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and
allow the casting feature to work</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false />
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false />
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
</dict>
</plist>
+1
View File
@@ -5,6 +5,7 @@ const Map<String, Locale> locales = {
'English (en)': Locale('en'),
// Additional locales
'Arabic (ar)': Locale('ar'),
'Basque (eu)': Locale('eu'),
'Bosnian (bl)': Locale('bn'),
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
'Bulgarian (bg)': Locale('bg'),
+1
View File
@@ -263,6 +263,7 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
child: MaterialApp.router(
title: 'Immich',
debugShowCheckedModeBanner: true,
scaffoldMessengerKey: scaffoldMessengerKey,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
+12 -3
View File
@@ -1,7 +1,16 @@
String? getVersionCompatibilityMessage(int _, int appMinor, int _, int serverMinor) {
import 'package:immich_mobile/utils/semver.dart';
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
// Add latest compat info up top
if (serverMinor < 106 && appMinor >= 106) {
return 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login';
// ensure mobile app major version is not behind server major version
if (appVersion.major < serverVersion.major) {
return 'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
}
// ensure mobile app major version is not ahead of server major version by more than 1 major version
if (appVersion.major > serverVersion.major + 1) {
return 'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
}
return null;
+4 -12
View File
@@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
@@ -88,18 +89,9 @@ class LoginForm extends HookConsumerWidget {
checkVersionMismatch() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
warningMessage.value = getVersionCompatibilityMessage(
appMajorVersion,
appMinorVersion,
serverMajorVersion,
serverMinorVersion,
);
final appSemVer = SemVer.fromString(packageInfo.version);
final serverSemVer = serverInfo.serverVersion;
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
} catch (error) {
warningMessage.value = 'Error checking version compatibility';
}
@@ -31,7 +31,7 @@ class AssetBulkUploadCheckResult {
///
Optional<String?> assetId;
/// Asset ID
/// Client-side identifier echoed from the request to match results to inputs
String id;
/// Whether existing asset is trashed
+3
View File
@@ -1,12 +1,15 @@
export 'src/components/close_button.dart';
export 'src/components/column_button.dart';
export 'src/components/form.dart';
export 'src/components/formatted_text.dart';
export 'src/components/icon_button.dart';
export 'src/components/menu_item.dart';
export 'src/components/password_input.dart';
export 'src/components/text_button.dart';
export 'src/components/text_input.dart';
export 'src/components/url_input.dart';
export 'src/constants.dart';
export 'src/snackbar.dart';
export 'src/theme.dart';
export 'src/translation.dart';
export 'src/types.dart';
@@ -0,0 +1,13 @@
import 'package:flutter/widgets.dart';
class ImmichColorOverride extends InheritedWidget {
const ImmichColorOverride({super.key, required this.color, required super.child});
final Color color;
static Color? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ImmichColorOverride>()?.color;
@override
bool updateShouldNotify(ImmichColorOverride oldWidget) => color != oldWidget.color;
}
@@ -16,10 +16,9 @@ class ImmichCloseButton extends StatelessWidget {
@override
Widget build(BuildContext context) => ImmichIconButton(
key: key,
icon: Icons.close,
color: color,
variant: variant,
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
icon: Icons.close,
color: color,
variant: variant,
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
}
@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichColumnButton extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final bool disabled;
final bool? loading;
const ImmichColumnButton({
super.key,
required this.icon,
required this.label,
required this.onPressed,
this.disabled = false,
this.loading,
});
@override
State<ImmichColumnButton> createState() => _ImmichColumnButtonState();
}
class _ImmichColumnButtonState extends State<ImmichColumnButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
return TextButton(
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: TextButton.styleFrom(
foregroundColor: foreground,
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
tapTargetSize: .shrinkWrap,
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.xl))),
),
child: ConstrainedBox(
constraints: const .new(maxWidth: 90),
child: Column(
mainAxisSize: .min,
children: [
_isLoading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: Icon(widget.icon, size: ImmichIconSize.md),
const SizedBox(height: ImmichSpacing.sm),
Text(
widget.label,
maxLines: 2,
textAlign: .center,
overflow: .ellipsis,
style: const .new(fontSize: ImmichTextSize.label, fontWeight: .w500),
),
],
),
),
);
}
}
@@ -88,7 +88,7 @@ class _ImmichFormState extends State<ImmichForm> {
builder: (context, _) => ImmichTextButton(
labelText: submitText,
icon: widget.submitIcon,
variant: ImmichVariant.filled,
variant: .filled,
loading: _controller.isLoading,
onPressed: _controller.submit,
disabled: _controller.onSubmit == null,
@@ -94,12 +94,12 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
final tag = match.group(1)!.toLowerCase();
final content = match.group(2)!;
final formattedSpan = (widget.spanBuilder ?? _defaultSpanBuilder)(tag);
final style = formattedSpan.style ?? _defaultTextStyle(tag);
final span = widget.spanBuilder?.call(tag);
final style = span?.style ?? _defaultTextStyle(tag);
GestureRecognizer? recognizer;
if (formattedSpan.onTap != null) {
recognizer = TapGestureRecognizer()..onTap = formattedSpan.onTap;
if (span?.onTap != null) {
recognizer = TapGestureRecognizer()..onTap = span!.onTap;
_recognizers.add(recognizer);
}
spans.add(TextSpan(text: content, style: style, recognizer: recognizer));
@@ -114,19 +114,12 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
return spans;
}
FormattedSpan _defaultSpanBuilder(String tag) => switch (tag) {
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
'link' => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ when tag.endsWith('-link') => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ => const FormattedSpan(),
};
TextStyle? _defaultTextStyle(String tag) => switch (tag) {
'b' => const TextStyle(fontWeight: FontWeight.bold),
'link' => const TextStyle(decoration: TextDecoration.underline),
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
_ => null,
};
'b' => const TextStyle(fontWeight: FontWeight.bold),
'link' => const TextStyle(decoration: TextDecoration.underline),
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
_ => null,
};
@override
Widget build(BuildContext context) {
@@ -1,54 +1,80 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/types.dart';
import 'dart:async';
class ImmichIconButton extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichIconButton extends StatefulWidget {
final IconData icon;
final VoidCallback onPressed;
final FutureOr<void> Function() onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
final bool? loading;
const ImmichIconButton({
super.key,
required this.icon,
required this.onPressed,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.filled,
this.color = .primary,
this.variant = .filled,
this.disabled = false,
this.loading,
});
@override
State<ImmichIconButton> createState() => _ImmichIconButtonState();
}
class _ImmichIconButtonState extends State<ImmichIconButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final background = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
ImmichVariant.ghost => Colors.transparent,
final background = switch (widget.variant) {
.filled => switch (widget.color) {
.primary => colorScheme.primary,
.secondary => colorScheme.secondary,
},
.ghost => Colors.transparent,
};
final foreground = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => colorScheme.onPrimary,
ImmichColor.secondary => colorScheme.onSecondary,
},
ImmichVariant.ghost => switch (color) {
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
};
final effectiveOnPressed = disabled ? null : onPressed;
final foreground =
context.colorOverride ??
switch (widget.variant) {
.filled => switch (widget.color) {
.primary => colorScheme.onPrimary,
.secondary => colorScheme.onSecondary,
},
.ghost => switch (widget.color) {
.primary => colorScheme.primary,
.secondary => colorScheme.secondary,
},
};
return IconButton(
icon: Icon(icon),
onPressed: effectiveOnPressed,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,
),
icon: _isLoading
? const SizedBox.square(
dimension: ImmichIconSize.sm,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
)
: Icon(widget.icon),
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
);
}
}
@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichMenu extends StatefulWidget {
final List<Widget> children;
final MenuAnchorChildBuilder builder;
final MenuStyle? style;
final bool consumeOutsideTap;
final Widget? child;
const ImmichMenu({
super.key,
required this.children,
required this.builder,
this.style,
this.consumeOutsideTap = false,
this.child,
});
@override
State<ImmichMenu> createState() => _ImmichMenuState();
}
class _ImmichMenuState extends State<ImmichMenu> {
final _controller = MenuController();
@override
Widget build(BuildContext context) {
return _ImmichMenuScope(
controller: _controller,
child: MenuAnchor(
controller: _controller,
style: widget.style,
consumeOutsideTap: widget.consumeOutsideTap,
menuChildren: widget.children,
builder: widget.builder,
child: widget.child,
),
);
}
}
class _ImmichMenuScope extends InheritedWidget {
final MenuController controller;
const _ImmichMenuScope({required this.controller, required super.child});
static MenuController? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_ImmichMenuScope>()?.controller;
@override
bool updateShouldNotify(_ImmichMenuScope oldWidget) => controller != oldWidget.controller;
}
class ImmichMenuItem extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final bool disabled;
const ImmichMenuItem({
super.key,
required this.icon,
required this.label,
required this.onPressed,
this.disabled = false,
});
@override
State<ImmichMenuItem> createState() => _ImmichMenuItemState();
}
class _ImmichMenuItemState extends State<ImmichMenuItem> {
Future<void> _onPressed(MenuController? controller) async {
try {
await widget.onPressed();
} finally {
controller?.close();
}
}
@override
Widget build(BuildContext context) {
final controller = _ImmichMenuScope.maybeOf(context);
return MenuItemButton(
onPressed: widget.disabled ? null : () => _onPressed(controller),
closeOnActivate: controller == null,
style: MenuItemButton.styleFrom(
foregroundColor: context.colorOverride,
alignment: .centerLeft,
padding: const .symmetric(horizontal: ImmichSpacing.lg, vertical: ImmichSpacing.md),
),
leadingIcon: Icon(widget.icon, size: ImmichIconSize.sm),
child: Text(widget.label, style: const .new(fontSize: ImmichTextSize.body)),
);
}
}
@@ -52,7 +52,6 @@ class _ImmichPasswordInputState extends State<ImmichPasswordInput> {
icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded),
),
autofillHints: [AutofillHints.password],
keyboardType: TextInputType.text,
);
}
}
@@ -1,85 +1,72 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/types.dart';
import 'package:immich_ui/immich_ui.dart';
class ImmichTextButton extends StatelessWidget {
class ImmichTextButton extends StatefulWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool expanded;
final bool loading;
final bool disabled;
final bool? loading;
const ImmichTextButton({
super.key,
required this.labelText,
this.icon,
required this.onPressed,
this.variant = ImmichVariant.filled,
this.color = ImmichColor.primary,
this.variant = .filled,
this.expanded = true,
this.loading = false,
this.disabled = false,
this.loading,
});
Widget _buildButton(ImmichVariant variant) {
final Widget? effectiveIcon = loading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: icon != null
? Icon(icon, fontWeight: FontWeight.w600)
: null;
final hasIcon = effectiveIcon != null;
@override
State<ImmichTextButton> createState() => _ImmichTextButtonState();
}
final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold));
final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md));
class _ImmichTextButtonState extends State<ImmichTextButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
final effectiveOnPressed = disabled || loading ? null : onPressed;
switch (variant) {
case ImmichVariant.filled:
if (hasIcon) {
return ElevatedButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return ElevatedButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
case ImmichVariant.ghost:
if (hasIcon) {
return TextButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return TextButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final button = _buildButton(variant);
if (expanded) {
final Widget? icon = _isLoading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: widget.icon != null
? Icon(widget.icon, fontWeight: .w600)
: null;
final label = Text(
widget.labelText,
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
);
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
final button = switch (widget.variant) {
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
};
if (widget.expanded) {
return SizedBox(width: double.infinity, child: button);
}
return button;
+5
View File
@@ -1,6 +1,11 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/color_override.dart';
import 'package:immich_ui/src/translation.dart';
extension TranslationHelper on BuildContext {
ImmichTranslations get translations => ImmichTranslationProvider.of(this);
}
extension ColorHelper on BuildContext {
Color? get colorOverride => ImmichColorOverride.maybeOf(this);
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/column_button.dart';
import 'package:immich_ui/src/previews.dart';
void _previewNoop() {}
@ImmichPreview(group: 'ColumnButton', name: 'Default')
Widget previewColumnButtonDefault() => const Wrap(
spacing: 12,
runSpacing: 12,
children: [
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.favorite_border_rounded, label: 'Favorite'),
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.archive_outlined, label: 'Archive'),
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete'),
],
);
@ImmichPreview(group: 'ColumnButton', name: 'Loading')
Widget previewColumnButtonLoading() => ImmichColumnButton(
onPressed: () => Future<void>.delayed(const .new(seconds: 2)),
icon: Icons.download,
label: 'Download',
);
@ImmichPreview(group: 'ColumnButton', name: 'Disabled')
Widget previewColumnButtonDisabled() =>
const ImmichColumnButton(onPressed: _previewNoop, icon: Icons.ios_share_rounded, label: 'Share', disabled: true);
@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/menu_item.dart';
import 'package:immich_ui/src/previews.dart';
void _previewNoop() {}
@ImmichPreview(group: 'MenuItem', name: 'Default')
Widget previewMenuItemDefault() => const Column(
mainAxisSize: MainAxisSize.min,
children: [
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.info_outline, label: 'Info'),
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.help_outline_rounded, label: 'Troubleshoot'),
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.cast_rounded, label: 'Cast'),
],
);
@ImmichPreview(group: 'MenuItem', name: 'Disabled')
Widget previewMenuItemDisabled() =>
const ImmichMenuItem(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete', disabled: true);
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/previews.dart';
import 'package:immich_ui/src/snackbar.dart';
@ImmichPreview(group: 'Snackbar', name: 'Types')
Widget previewSnackbarTypes() => const _SnackbarDemo();
class _SnackbarDemo extends StatelessWidget {
const _SnackbarDemo();
@override
Widget build(BuildContext context) {
return ScaffoldMessenger(
key: scaffoldMessengerKey,
child: Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: Wrap(
spacing: ImmichSpacing.md,
runSpacing: ImmichSpacing.md,
children: [
ElevatedButton(onPressed: () => snackbar.info('Info message'), child: const Text('Info')),
ElevatedButton(onPressed: () => snackbar.success('Saved'), child: const Text('Success')),
ElevatedButton(onPressed: () => snackbar.error('Something failed'), child: const Text('Error')),
],
),
),
),
);
}
}
@@ -15,16 +15,6 @@ Widget previewTextButtonVariants() => const Wrap(
],
);
@ImmichPreview(group: 'TextButton', name: 'Colors')
Widget previewTextButtonColors() => const Wrap(
spacing: 12,
runSpacing: 12,
children: [
ImmichTextButton(onPressed: _previewNoop, labelText: 'Primary', expanded: false),
ImmichTextButton(onPressed: _previewNoop, labelText: 'Secondary', color: ImmichColor.secondary, expanded: false),
],
);
@ImmichPreview(group: 'TextButton', name: 'With Icons')
Widget previewTextButtonWithIcons() => const Wrap(
spacing: 12,
@@ -42,7 +32,11 @@ Widget previewTextButtonWithIcons() => const Wrap(
);
@ImmichPreview(group: 'TextButton', name: 'Loading')
Widget previewTextButtonLoading() => const _PreviewLoadingDemo();
Widget previewTextButtonLoading() => ImmichTextButton(
onPressed: () => Future<void>.delayed(const Duration(seconds: 2)),
labelText: 'Click me',
expanded: false,
);
@ImmichPreview(group: 'TextButton', name: 'Disabled')
Widget previewTextButtonDisabled() => const Wrap(
@@ -59,30 +53,3 @@ Widget previewTextButtonDisabled() => const Wrap(
),
],
);
class _PreviewLoadingDemo extends StatefulWidget {
const _PreviewLoadingDemo();
@override
State<_PreviewLoadingDemo> createState() => _PreviewLoadingDemoState();
}
class _PreviewLoadingDemoState extends State<_PreviewLoadingDemo> {
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return ImmichTextButton(
onPressed: () async {
setState(() => _isLoading = true);
await Future<void>.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() => _isLoading = false);
}
},
labelText: _isLoading ? 'Loading...' : 'Click Me',
loading: _isLoading,
expanded: false,
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class SnackbarManager {
const SnackbarManager();
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
final messenger = scaffoldMessengerKey.currentState;
final context = scaffoldMessengerKey.currentContext;
if (messenger == null || context == null) {
return null;
}
messenger.hideCurrentSnackBar();
return messenger.showSnackBar(_build(context, message, type));
}
SnackBar _build(BuildContext context, String message, SnackbarType type) {
final theme = Theme.of(context);
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
final (IconData icon, Color background, Color foreground) = switch (type) {
.info => (Icons.info_rounded, colors.info, colors.onInfo),
.success => (Icons.check_circle_rounded, colors.success, colors.onSuccess),
.error => (Icons.warning_rounded, colors.error, colors.onError),
};
return SnackBar(
behavior: .floating,
backgroundColor: background,
duration: const .new(seconds: 4),
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
content: Row(
children: [
Icon(icon, color: foreground, size: ImmichIconSize.sm),
const SizedBox(width: ImmichSpacing.md),
Expanded(
child: Text(
message,
maxLines: 2,
overflow: .ellipsis,
style: .new(color: foreground, fontWeight: .w600, fontSize: ImmichTextSize.body),
),
),
],
),
);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
}
const snackbar = SnackbarManager();
+74 -2
View File
@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:material_color_utilities/blend/blend.dart';
import 'package:material_color_utilities/hct/hct.dart';
import 'package:material_color_utilities/palettes/tonal_palette.dart';
class ImmichThemeProvider extends StatelessWidget {
final ColorScheme colorScheme;
@@ -11,6 +14,7 @@ class ImmichThemeProvider extends StatelessWidget {
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
extensions: [ImmichColors.harmonized(colorScheme)],
colorScheme: colorScheme,
brightness: colorScheme.brightness,
inputDecorationTheme: InputDecorationTheme(
@@ -19,8 +23,8 @@ class ImmichThemeProvider extends StatelessWidget {
final color = states.contains(WidgetState.error)
? colorScheme.error
: states.contains(WidgetState.focused)
? colorScheme.primary
: colorScheme.outline;
? colorScheme.primary
: colorScheme.outline;
return OutlineInputBorder(
borderSide: BorderSide(color: color),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
@@ -38,3 +42,71 @@ class ImmichThemeProvider extends StatelessWidget {
);
}
}
class ImmichColors extends ThemeExtension<ImmichColors> {
final Color info;
final Color onInfo;
final Color success;
final Color onSuccess;
final Color error;
final Color onError;
const ImmichColors({
required this.info,
required this.onInfo,
required this.success,
required this.onSuccess,
required this.error,
required this.onError,
});
factory ImmichColors.harmonized(ColorScheme scheme) {
final (info, onInfo) = scheme.harmonized(const Color(0xFF1984E9));
final (success, onSuccess) = scheme.harmonized(const Color(0xFF10C14D));
final (error, onError) = scheme.harmonized(const Color(0xFFFA2921));
return ImmichColors(
info: info,
onInfo: onInfo,
success: success,
onSuccess: onSuccess,
error: error,
onError: onError,
);
}
@override
ImmichColors copyWith({Color? info, Color? onInfo, Color? success, Color? onSuccess, Color? error, Color? onError}) {
return ImmichColors(
info: info ?? this.info,
onInfo: onInfo ?? this.onInfo,
success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess,
error: error ?? this.error,
onError: onError ?? this.onError,
);
}
@override
ImmichColors lerp(ImmichColors? other, double t) {
if (other == null) {
return this;
}
return ImmichColors(
info: Color.lerp(info, other.info, t)!,
onInfo: Color.lerp(onInfo, other.onInfo, t)!,
success: Color.lerp(success, other.success, t)!,
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
error: Color.lerp(error, other.error, t)!,
onError: Color.lerp(onError, other.onError, t)!,
);
}
}
extension on ColorScheme {
(Color container, Color onContainer) harmonized(Color seed) {
final hct = Hct.fromInt(Blend.harmonize(seed.toARGB32(), primary.toARGB32()));
final tones = TonalPalette.of(hct.hue, hct.chroma);
final isDark = brightness == Brightness.dark;
return (Color(tones.get(isDark ? 30 : 90)), Color(tones.get(isDark ? 90 : 10)));
}
}
+4 -8
View File
@@ -1,9 +1,5 @@
enum ImmichVariant {
filled,
ghost,
}
enum ImmichVariant { filled, ghost }
enum ImmichColor {
primary,
secondary,
}
enum ImmichColor { primary, secondary }
enum SnackbarType { info, success, error }
+1 -1
View File
@@ -92,7 +92,7 @@ packages:
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
dependency: "direct main"
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+1
View File
@@ -7,6 +7,7 @@ environment:
dependencies:
flutter:
sdk: flutter
material_color_utilities: any
dev_dependencies:
flutter_test:
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/color_override.dart';
import 'package:immich_ui/src/components/icon_button.dart';
import 'test_utils.dart';
void main() {
group('ImmichColorOverride', () {
testWidgets('exposes the override color to descendants', (tester) async {
Color? captured;
await tester.pumpTestWidget(
ImmichColorOverride(
color: Colors.green,
child: Builder(
builder: (context) {
captured = ImmichColorOverride.maybeOf(context);
return const SizedBox.shrink();
},
),
),
);
expect(captured, Colors.green);
});
testWidgets('maybeOf returns null when there is no override', (tester) async {
Color? captured = Colors.black;
await tester.pumpTestWidget(
Builder(
builder: (context) {
captured = ImmichColorOverride.maybeOf(context);
return const SizedBox.shrink();
},
),
);
expect(captured, isNull);
});
testWidgets('a descendant component adopts the override as its foreground', (tester) async {
await tester.pumpTestWidget(
ImmichColorOverride(
color: Colors.green,
child: ImmichIconButton(icon: Icons.add, onPressed: () {}),
),
);
final button = tester.widget<IconButton>(find.byType(IconButton));
expect(button.style?.foregroundColor?.resolve(<WidgetState>{}), Colors.green);
});
});
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/snackbar.dart';
import 'test_utils.dart';
void main() {
group('SnackbarManager', () {
testWidgets('shows the message', (tester) async {
await tester.pumpTestWidget(const SizedBox());
snackbar.success('hello');
await tester.pump();
expect(find.text('hello'), findsOneWidget);
expect(find.byType(SnackBar), findsOneWidget);
});
testWidgets('replaces the current snackbar', (tester) async {
await tester.pumpTestWidget(const SizedBox());
snackbar.info('first');
await tester.pump();
snackbar.error('second');
await tester.pump();
expect(find.text('first'), findsNothing);
expect(find.text('second'), findsOneWidget);
});
testWidgets('no-ops when the messenger is unmounted', (tester) async {
expect(snackbar.show('x', .info), isNull);
});
});
}
+7 -2
View File
@@ -1,9 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/snackbar.dart';
extension WidgetTesterExtension on WidgetTester {
/// Pumps a widget wrapped in MaterialApp and Scaffold for testing.
Future<void> pumpTestWidget(Widget widget) {
return pumpWidget(MaterialApp(home: Scaffold(body: widget)));
return pumpWidget(
MaterialApp(
scaffoldMessengerKey: scaffoldMessengerKey,
home: Scaffold(body: widget),
),
);
}
}
+34 -34
View File
@@ -69,10 +69,10 @@ packages:
dependency: "direct main"
description:
name: background_downloader
sha256: "4cb23d9ad4f5060944f38164e7b90d4bf99b57b2472a3bd4676e59b2db4afd06"
sha256: aceacec2b2a72ec3a8862ab5895fcbbc71ab33765f3619d57963f3110dd268e3
url: "https://pub.dev"
source: hosted
version: "9.5.4"
version: "9.5.5"
bonsoir:
dependency: "direct overridden"
description:
@@ -229,10 +229,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.2.1"
code_builder:
dependency: transitive
description:
@@ -326,18 +326,18 @@ packages:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
version: "0.7.13"
desktop_webview_window:
dependency: transitive
description:
name: desktop_webview_window
sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0"
sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957
url: "https://pub.dev"
source: hosted
version: "0.2.3"
version: "0.3.0"
device_info_plus:
dependency: "direct main"
description:
@@ -549,18 +549,18 @@ packages:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff"
url: "https://pub.dev"
source: hosted
version: "2.4.7"
version: "2.4.8"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.34"
version: "2.0.35"
flutter_riverpod:
dependency: transitive
description:
@@ -642,10 +642,10 @@ packages:
dependency: "direct main"
description:
name: flutter_web_auth_2
sha256: d354998934ddc338e69b999b2abaeb33c6fd09999d3a5f92ead1a6b49b49712e
sha256: "8f9303471dcd96670878c9b7c0c4e14c37595b2add67465f6a868f17a5872dfc"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "5.0.3"
flutter_web_auth_2_platform_interface:
dependency: transitive
description:
@@ -780,10 +780,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
version: "2.0.2"
hooks_riverpod:
dependency: "direct main"
description:
@@ -844,10 +844,10 @@ packages:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
url: "https://pub.dev"
source: hosted
version: "4.8.0"
version: "4.9.1"
image_picker:
dependency: "direct main"
description:
@@ -860,10 +860,10 @@ packages:
dependency: transitive
description:
name: image_picker_android
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
url: "https://pub.dev"
source: hosted
version: "0.8.13+17"
version: "0.8.13+19"
image_picker_for_web:
dependency: transitive
description:
@@ -1120,10 +1120,10 @@ packages:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
url: "https://pub.dev"
source: hosted
version: "0.17.6"
version: "0.19.1"
native_video_player:
dependency: "direct main"
description:
@@ -1161,10 +1161,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
version: "9.4.1"
octo_image:
dependency: "direct main"
description:
@@ -1297,10 +1297,10 @@ packages:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
sha256: e20daf680eef1ca62ffe8c8c526b778cc386d50137c77ac71c8ec9c88c13fb9d
url: "https://pub.dev"
source: hosted
version: "9.4.7"
version: "9.4.9"
permission_handler_html:
dependency: transitive
description:
@@ -1529,10 +1529,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0
url: "https://pub.dev"
source: hosted
version: "2.4.23"
version: "2.4.25"
shared_preferences_foundation:
dependency: transitive
description:
@@ -1791,10 +1791,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev"
source: hosted
version: "6.3.30"
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
@@ -1871,10 +1871,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e
sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172"
url: "https://pub.dev"
source: hosted
version: "1.2.3"
version: "1.2.5"
vector_math:
dependency: transitive
description:
@@ -1991,10 +1991,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
version: "7.0.1"
xxh3:
dependency: transitive
description:
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.0.0-rc.2+3049
version: 3.0.0-rc.2+3050
environment:
sdk: '>=3.12.0 <4.0.0'
@@ -1,29 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
void main() {
test('getVersionCompatibilityMessage', () {
String? result;
group('app major version behind server', () {
const message =
'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
result = getVersionCompatibilityMessage(1, 106, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
test('returns message when app major is behind server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 1, minor: 200, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 107, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
test('returns null when app major matches server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
});
result = getVersionCompatibilityMessage(1, 106, 1, 106);
expect(result, null);
group('app major version too far ahead of server', () {
const message =
'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
result = getVersionCompatibilityMessage(1, 107, 1, 106);
expect(result, null);
test('returns message when app major is more than one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 3, minor: 0, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 107, 1, 108);
expect(result, null);
test('returns null when app major is exactly one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
});
}
+3 -3
View File
@@ -17012,12 +17012,12 @@
},
"assetId": {
"description": "Existing asset ID if duplicate",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"id": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"description": "Client-side identifier echoed from the request to match results to inputs",
"type": "string"
},
"isTrashed": {
+11 -3
View File
@@ -278,7 +278,9 @@
"title": "Album IDs",
"array": true,
"description": "Target album IDs",
"uiHint": "AlbumId"
"uiHint": {
"type": "AlbumId"
}
},
"albumName": {
"type": "string",
@@ -368,14 +370,20 @@
"type": "string",
"title": "Album ID",
"description": "Target album ID",
"uiHint": "AlbumId"
"uiHint": {
"type": "AlbumId",
"order": 1
}
},
"albumIds": {
"type": "string",
"title": "Album IDs",
"description": "Target album IDs",
"array": true,
"uiHint": "AlbumId"
"uiHint": {
"type": "AlbumId",
"order": 2
}
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "src/index.ts",
"scripts": {
"build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "tsc --noEmit && node esbuild.js",
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
},
"keywords": [],
+21 -21
View File
@@ -1,5 +1,11 @@
import { wrapper } from '@immich/plugin-sdk';
import { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
import { getWrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type manifestType from '../dist/manifest';
const wrapper = getWrapper<manifestType>();
type Foo = (manifestType['methods'][number] & {
name: 'assetMissingTimeZoneFilter';
})['schema']['properties']['inverse']['type'];
type AssetFileFilterConfig = {
pattern: string;
@@ -7,7 +13,7 @@ type AssetFileFilterConfig = {
caseSensitive?: boolean;
};
export const assetFileFilter = () => {
return wrapper<WorkflowType.AssetV1, AssetFileFilterConfig>(({ data, config }) => {
return wrapper<'assetFileFilter'>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const { asset } = data;
@@ -43,7 +49,7 @@ export const assetFileFilter = () => {
};
export const assetMissingTimeZoneFilter = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
@@ -51,13 +57,7 @@ export const assetMissingTimeZoneFilter = () => {
};
export const assetLocationFilter = () => {
return wrapper<
WorkflowType.AssetV1,
{
region?: { country?: string; state?: string; city?: string };
coordinate?: { latitude?: string; longitude?: string; radius?: number };
}
>(({ config, data }) => {
return wrapper<'assetLocationFilter'>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
@@ -96,13 +96,13 @@ export const assetLocationFilter = () => {
};
export const assetTypeFilter = () => {
return wrapper<WorkflowType.AssetV1, { allowedTypes: AssetTypeEnum[] }>(({ config, data }) => {
return wrapper<'assetTypeFilter'>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
};
export const assetFavorite = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetFavorite'>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
@@ -115,13 +115,13 @@ export const assetFavorite = () => {
};
export const assetVisibility = () => {
return wrapper<WorkflowType.AssetV1, { visibility: AssetVisibility }>(({ config }) => ({
return wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility } },
}));
};
export const assetArchive = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetArchive'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
@@ -135,7 +135,7 @@ export const assetArchive = () => {
};
export const assetLock = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetLock'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
@@ -148,13 +148,13 @@ export const assetLock = () => {
});
};
export const assetTrash = () => {
// TODO use trash/untrash host functions
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
};
// export const assetTrash = () => {
// // TODO use trash/untrash host functions
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
// };
export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
+1 -1
View File
@@ -31,7 +31,7 @@
"@types/node": "^24.13.2",
"esbuild": "^0.28.0",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3"
"typescript": "^6.0.0"
},
"peerDependencies": {
"@extism/js-pdk": "^1.1.1"
+86 -44
View File
@@ -1,53 +1,95 @@
import type { WorkflowType } from '@immich/sdk';
import { hostFunctions } from 'src/host-functions.js';
import type {
ConfigValue,
WorkflowEventPayload,
WorkflowResponse,
WorkflowStepConfig,
} from 'src/types.js';
export const wrapper = <
T extends WorkflowType = WorkflowType,
TConfig extends ConfigValue = ConfigValue,
>(
fn: (
payload: WorkflowEventPayload<T, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<T> | undefined,
) => {
const input = Host.inputString();
try {
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
}
type Property = {
type: 'string' | 'boolean' | 'number' | 'object';
array?: boolean;
enum?: string[];
};
type RequiredProperties<
Properties extends { [K: string]: unknown },
Required extends string[] | undefined,
RequiredKeys extends string = Required extends undefined
? never
: NonNullable<Required>[number],
> = {
properties: Pick<Properties, RequiredKeys> &
Partial<Omit<Properties, RequiredKeys>>;
};
type GetConfigType<T extends Property> = 'enum' extends keyof T
? NonNullable<T['enum']>[number]
: T['type'] extends 'boolean'
? boolean
: T['type'] extends 'number'
? number
: T['type'] extends 'string'
? string
: object;
type ConfigValue<
T extends { properties: { [K: string]: Property }; required?: string[] },
Properties extends { [K: string]: Property } = T['properties'],
> = T extends never
? never
: RequiredProperties<
{
[K in keyof Properties]: Properties[K]['array'] extends true
? Array<GetConfigType<Properties[K]>>
: GetConfigType<Properties[K]>;
},
'required' extends keyof T ? T['required'] : undefined
>['properties'];
export const getWrapper =
<T extends Record<string, any>>() =>
<
K extends T['methods'][number]['name'],
L extends WorkflowType = (T['methods'][number] & { name: K })['types'][0],
TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>,
>(
fn: (
payload: WorkflowEventPayload<L, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<L> | undefined,
) => {
const input = Host.inputString();
try {
const payload = JSON.parse(input) as WorkflowEventPayload<K, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
}
};
+6 -5
View File
@@ -11,7 +11,7 @@ type DeepPartial<T> = T extends Date
export type WorkflowEventMap = {
[WorkflowType.AssetV1]: AssetV1;
// [WorkflowType.AssetPersonV1]: AssetPersonV1;
};
} & { [K in WorkflowType]: unknown };
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
@@ -22,7 +22,7 @@ export enum WorkflowTrigger {
}
export type WorkflowEventPayload<
T extends WorkflowType = WorkflowType,
T extends WorkflowType,
TConfig = WorkflowStepConfig,
> = {
trigger: WorkflowTrigger;
@@ -37,10 +37,11 @@ export type WorkflowEventPayload<
};
};
export type WorkflowChanges<T extends WorkflowType = WorkflowType> =
DeepPartial<WorkflowEventData<T>>;
export type WorkflowChanges<T extends WorkflowType> = DeepPartial<
WorkflowEventData<T>
>;
export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
export type WorkflowResponse<T extends WorkflowType> = {
workflow?: {
/** stop the workflow */
continue?: boolean;
+1
View File
@@ -20,6 +20,7 @@
"sourceMap": false,
"strict": true,
"target": "esnext",
"typeRoots": ["./node_modules/@types", "./node_modules"],
"types": ["node", "@extism/js-pdk"],
"verbatimModuleSyntax": true
}
+1 -1
View File
@@ -707,7 +707,7 @@ export type AssetBulkUploadCheckResult = {
action: AssetUploadAction;
/** Existing asset ID if duplicate */
assetId?: string;
/** Asset ID */
/** Client-side identifier echoed from the request to match results to inputs */
id: string;
/** Whether existing asset is trashed */
isTrashed?: boolean;
+611 -552
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -29,7 +29,7 @@ allowBuilds:
postman-code-generators: false
overrides:
canvas: 3.2.3
sharp: ^0.35.1
sharp: ^0.34.5
packageExtensions:
nestjs-kysely:
dependencies:
+2 -2
View File
@@ -38,8 +38,8 @@
</p>
> [!WARNING]
> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos!
>
> ⚠️ Değerli fotoğraflarınız ve videolarınız için daima [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) yedekleme planını uygulayın!
>
> [!NOTE]
> Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz.
+5 -6
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202606180900@sha256:3871e19b02c37d0e3d2ee200e4977e0f2afc77730fd8dcc5e4532b6e2b26bdce AS builder
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -20,9 +20,8 @@ RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \
pnpm --filter immich --prod deploy /output/server-pruned && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --dir /output/server-pruned/node_modules/sharp exec npm run build
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --prod --no-optional deploy /output/server-pruned
FROM builder AS web
@@ -38,7 +37,7 @@ RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile --force && \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile --force && \
pnpm --filter @immich/sdk --filter immich-web build
FROM builder AS cli
@@ -81,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins
FROM ghcr.io/immich-app/base-server-prod:202606180900@sha256:442159fae88a04b01a4caafbd9813f08aeefb2e1ae3b8a47cc82409208dfbd80
FROM ghcr.io/immich-app/base-server-prod:202606161235@sha256:c6d59e3923f548d29a212b4dc51b6281a722cfa1da7972a009c0f3830f5762d6
WORKDIR /usr/src/app
ENV NODE_ENV=production \
+1 -1
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202606180900@sha256:3871e19b02c37d0e3d2ee200e4977e0f2afc77730fd8dcc5e4532b6e2b26bdce AS dev
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS dev
COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise
+2 -2
View File
@@ -107,7 +107,7 @@
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"semver": "^7.8.1",
"sharp": "^0.35.1",
"sharp": "^0.34.5",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
"tailwindcss-preset-email": "^1.4.0",
@@ -168,6 +168,6 @@
"vitest": "^3.0.0"
},
"overrides": {
"sharp": "^0.35.1"
"sharp": "^0.34.5"
}
}
+2 -2
View File
@@ -34,10 +34,10 @@ const AssetRejectReasonSchema = z
const AssetBulkUploadCheckResultSchema = z
.object({
id: z.uuidv4().describe('Asset ID'),
id: z.string().describe('Client-side identifier echoed from the request to match results to inputs'),
action: AssetUploadActionSchema,
reason: AssetRejectReasonSchema.optional(),
assetId: z.string().optional().describe('Existing asset ID if duplicate'),
assetId: z.uuidv4().optional().describe('Existing asset ID if duplicate'),
isTrashed: z.boolean().optional().describe('Whether existing asset is trashed'),
})
.meta({ id: 'AssetBulkUploadCheckResult' });
+6 -1
View File
@@ -14,7 +14,12 @@ const JsonSchemaPropertySchema = z
enum: z.array(z.string()).optional().describe('Valid choices for enum types'),
array: z.boolean().optional().describe('Type is an array type'),
required: z.array(z.string()).optional().describe('A list of required properties'),
uiHint: z.string().optional(),
uiHint: z
.object({
type: z.string().optional(),
order: z.int().optional(),
})
.optional(),
get properties() {
return z.record(z.string(), JsonSchemaPropertySchema).optional();
},
@@ -129,6 +129,7 @@ from
and "integrity_report"."type" = $1
where
"asset"."deletedAt" is null
and "asset"."isExternal" = false
and "integrity_report"."createdAt" >= $2
and "integrity_report"."createdAt" <= $3
order by
+7 -7
View File
@@ -88,7 +88,7 @@ from
where
"album_asset"."updateId" < $3
and "album_asset"."updateId" <= $4
and "album_asset"."updateId" >= $5
and "album_asset"."updateId" > $5
and "album_asset"."albumId" = $6
order by
"album_asset"."updateId" asc
@@ -202,7 +202,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" >= $3
and "album_asset"."updateId" > $3
and "album_asset"."albumId" = $4
order by
"album_asset"."updateId" asc
@@ -297,7 +297,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" >= $3
and "album_asset"."updateId" > $3
and "album_asset"."albumId" = $4
order by
"album_asset"."updateId" asc
@@ -349,7 +349,7 @@ from
where
"album_user"."updateId" < $1
and "album_user"."updateId" <= $2
and "album_user"."updateId" >= $3
and "album_user"."updateId" > $3
and "albumId" = $4
order by
"album_user"."updateId" asc
@@ -810,7 +810,7 @@ from
where
"asset"."updateId" < $2
and "asset"."updateId" <= $3
and "asset"."updateId" >= $4
and "asset"."updateId" > $4
and "ownerId" = $5
order by
"asset"."updateId" asc
@@ -908,7 +908,7 @@ from
where
"asset_exif"."updateId" < $1
and "asset_exif"."updateId" <= $2
and "asset_exif"."updateId" >= $3
and "asset_exif"."updateId" > $3
and "asset"."ownerId" = $4
order by
"asset_exif"."updateId" asc
@@ -997,7 +997,7 @@ from
where
"stack"."updateId" < $1
and "stack"."updateId" <= $2
and "stack"."updateId" >= $3
and "stack"."updateId" > $3
and "ownerId" = $4
order by
"stack"."updateId" asc
@@ -177,6 +177,7 @@ export class IntegrityRepository {
'asset.id as assetId',
'integrity_report.id as reportId',
])
.where('asset.isExternal', '=', sql.lit(false))
.$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
.$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
.orderBy('integrity_report.createdAt', 'asc')
+1 -1
View File
@@ -106,7 +106,7 @@ export class BaseSync {
.selectFrom(table(t).as(t))
.where(updateIdRef, '<', nowId)
.where(updateIdRef, '<=', beforeUpdateId)
.$if(!!afterUpdateId, (qb) => qb.where(updateIdRef, '>=', afterUpdateId!))
.$if(!!afterUpdateId, (qb) => qb.where(updateIdRef, '>', afterUpdateId!))
.orderBy(updateIdRef, 'asc');
}
@@ -2939,6 +2939,8 @@ describe(MediaService.name, () => {
'7',
'-global_quality:v',
'23',
'-b:v',
'6897k',
'-maxrate',
'10000k',
'-bufsize',
@@ -369,7 +369,7 @@ export class WorkflowExecutionService extends BaseService {
const readResult = await read(type);
let data = readResult.data;
for (const step of workflow.steps) {
const payload: WorkflowEventPayload = {
const payload: WorkflowEventPayload<typeof type> = {
trigger: workflow.trigger,
type,
config: step.config ?? {},
+6
View File
@@ -788,6 +788,12 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
const options = [`-${this.useCQP() ? 'q:v' : 'global_quality:v'}`, `${this.config.crf}`];
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) {
// Workaround for https://github.com/immich-app/immich/issues/29220, to be revisited
// QSV seems to ignore -maxrate without -b:v
// -b:v alongside global_quality uses QVBR
if (!this.useCQP()) {
options.push('-b:v', `${bitrates.target}${bitrates.unit}`);
}
options.push('-maxrate', `${bitrates.max}${bitrates.unit}`, '-bufsize', `${bitrates.max * 2}${bitrates.unit}`);
}
return options;
@@ -686,6 +686,22 @@ describe(IntegrityService.name, () => {
nextCursor: undefined,
});
});
it('should skip external library files', async () => {
const { sut, ctx } = setup();
const job = ctx.getMock(JobRepository);
job.queue.mockResolvedValue(void 0);
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, isExternal: true });
await sut.handleChecksumFiles({ refreshOnly: false });
await expect(
ctx.get(IntegrityRepository).getIntegrityReport({ limit: 100 }, IntegrityReport.ChecksumFail),
).resolves.toEqual({ items: [], nextCursor: undefined });
});
});
describe('handleChecksumRefresh', () => {
@@ -155,6 +155,57 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
it('should not resend an already-acked item when backfill resumes', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
// backfill needs assets with an older updateId
const { asset: sharedAsset1 } = await ctx.newAsset({ ownerId: user2.id });
const { asset: sharedAsset2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
const { album: sharedAlbum } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: sharedAlbum.id, assetId: sharedAsset1.id });
await ctx.newAlbumAsset({ albumId: sharedAlbum.id, assetId: sharedAsset2.id });
await wait(2);
// backfill needs an initial ack, otherwise it syncs everything
const { asset: ownedAsset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album: ownedAlbum } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: ownedAlbum.id, assetId: ownedAsset.id });
const setupResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
await ctx.syncAckAll(auth, setupResponse);
// share album to trigger backfill
await ctx.newAlbumUser({ albumId: sharedAlbum.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response1 = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response1).toEqual([
// receive both
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset1.id } }),
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset2.id } }),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack 1st
await ctx.sut.setAcks(auth, { acks: [response1[0].ack] });
const response2 = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response2).toEqual([
// receive 2nd
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset2.id } }),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response2);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
it('should detect and sync a deleted album to asset relation', async () => {
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
@@ -279,6 +279,68 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
});
it('should not resend an already-acked item when backfill resumes', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
// backfill needs assets with an older updateId
const { asset: partnerAsset1 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
const { asset: partnerAsset2 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
// backfill needs an initial ack, otherwise it syncs everything
const { asset: initialAsset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const setupResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(setupResponse).toEqual([
expect.objectContaining({
data: expect.objectContaining({ id: initialAsset.id }),
type: SyncEntityType.PartnerAssetV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, setupResponse);
// partner share to trigger backfill
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const response1 = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(response1).toEqual([
// receive both
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset1.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset2.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack 1st
await ctx.sut.setAcks(auth, { acks: [response1[0].ack] });
const response2 = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(response2).toEqual([
// receive 2nd
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset2.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response2);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
});
it('should hide isFavorite for partner assets', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
+3 -1
View File
@@ -159,7 +159,9 @@
}
.text-white-shadow {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
text-shadow:
0 0 4px rgba(0, 0, 0, 0.9),
0 1px 3px rgba(0, 0, 0, 0.8);
}
.icon-white-drop-shadow {
@@ -51,6 +51,8 @@
};
const setUiHintValue = (values: string[]) => setValue(schema.array ? values : values[0]);
const getSchemaProperties = (schema: JSONSchemaProperty) =>
Object.entries(schema.properties ?? {}).sort((a, b) => (a[1].uiHint?.order ?? 0) - (b[1].uiHint?.order ?? 0));
const getBoolean = (defaultValue = false) => getValue<boolean>(defaultValue);
const getString = () => getValue<string>();
@@ -72,11 +74,11 @@
</div>
{/if}
<div class="flex flex-col gap-4 {root ? '' : 'border-l-4 border-gray-200 ps-2'}">
{#each Object.entries(schema.properties ?? {}) as [childKey, childSchema] (childKey)}
{#each getSchemaProperties(schema) as [childKey, childSchema] (childKey)}
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
{/each}
</div>
{:else if schema.uiHint === 'AlbumId'}
{:else if schema.uiHint?.type === 'AlbumId'}
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
{:else if schema.enum && schema.array}
<Field {label} {description}>
@@ -110,11 +110,11 @@
let sharedLink = getSharedLink();
let fullscreenElement = $state<Element>();
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let isPlayingOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>();
const setPlayOriginalVideo = (value: boolean) => {
playOriginalVideo = value;
isPlayingOriginalVideo = value;
};
const refreshStack = async () => {
@@ -499,14 +499,12 @@
{album}
{person}
{stack}
showSlideshow={true}
preAction={handlePreAction}
onAction={handleAction}
{onUndoDelete}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
{onRemoveFromAlbum}
{playOriginalVideo}
{isPlayingOriginalVideo}
{setPlayOriginalVideo}
/>
</div>
@@ -544,7 +542,7 @@
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
playOriginalVideo={isPlayingOriginalVideo}
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
@@ -556,7 +554,7 @@
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo}
playOriginalVideo={isPlayingOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer {asset} />
@@ -576,7 +574,7 @@
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
playOriginalVideo={isPlayingOriginalVideo}
/>
{/if}
@@ -1,5 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToStackAction from '$lib/components/asset-viewer/actions/AddToStackAction.svelte';
@@ -9,21 +8,17 @@
import RatingAction from '$lib/components/asset-viewer/actions/RatingAction.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/RestoreAction.svelte';
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/SetAlbumCoverAction.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/SetVisibilityAction.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/UnstackAction.svelte';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { Route } from '$lib/route';
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { getSharedLink, withoutIcons } from '$lib/utils';
@@ -38,15 +33,7 @@
type StackResponseDto,
} from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
import {
mdiArrowLeft,
mdiArrowRight,
mdiCompare,
mdiDotsVertical,
mdiImageSearch,
mdiPresentationPlay,
mdiVideoOutline,
} from '@mdi/js';
import { mdiArrowLeft, mdiArrowRight, mdiDotsVertical, mdiVideoOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -54,14 +41,12 @@
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
showSlideshow?: boolean;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
onPlaySlideshow: () => void;
onClose?: () => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
playOriginalVideo: boolean;
isPlayingOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
}
@@ -70,21 +55,18 @@
album = null,
person = null,
stack = null,
showSlideshow = false,
preAction,
onAction,
onUndoDelete = undefined,
onPlaySlideshow,
onClose,
onRemoveFromAlbum,
playOriginalVideo = false,
isPlayingOriginalVideo = false,
setPlayOriginalVideo,
}: Props = $props();
const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id);
const isAlbumOwner = $derived(authManager.authenticated && album?.albumUsers[0].user.id === authManager.user.id);
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const { Cast } = $derived(getGlobalActions($t));
@@ -96,7 +78,14 @@
shortcuts: [{ key: 'Escape' }],
});
const Actions = $derived(getAssetActions($t, asset));
const PlayOriginalVideo: ActionItem = $derived({
title: isPlayingOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video'),
icon: mdiVideoOutline,
$if: () => asset.type === AssetTypeEnum.Video,
onAction: () => setPlayOriginalVideo(!isPlayingOriginalVideo),
});
const Actions = $derived(getAssetActions($t, { ...asset, stackPrimaryAssetId: stack?.primaryAssetId }));
const sharedLink = getSharedLink();
</script>
@@ -147,9 +136,7 @@
{#if !sharedLink}
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
{#if showSlideshow && !isLocked}
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
<ActionMenuItem action={Actions.PlaySlideshow} />
<ActionMenuItem action={Actions.Download} />
<ActionMenuItem action={Actions.DownloadOriginal} />
@@ -177,46 +164,27 @@
{/if}
{/if}
{#if album}
<SetAlbumCoverAction {asset} {album} />
{@const { SetCover } = getAlbumAssetActions($t, album, asset)}
<ActionMenuItem action={SetCover} />
{/if}
{#if person}
<SetFeaturedPhotoAction {asset} {person} {onAction} />
{/if}
{#if asset.type === AssetTypeEnum.Image && !isLocked}
<SetProfilePictureAction {asset} />
{/if}
{#if !isLocked}
{#if isOwner}
<ArchiveAction {asset} {onAction} {preAction} />
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id }))}
text={$t('view_in_timeline')}
/>
{/if}
{/if}
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
<MenuOption
icon={mdiCompare}
onClick={() => goto(Route.search({ queryAssetId: stack?.primaryAssetId ?? asset.id }))}
text={$t('view_similar_photos')}
/>
{/if}
<ActionMenuItem action={Actions.SetProfilePicture} />
{#if isOwner && !isLocked}
<ArchiveAction {asset} {onAction} {preAction} />
{/if}
<ActionMenuItem action={Actions.ViewInTimeline} />
<ActionMenuItem action={Actions.ViewSimilar} />
{#if !asset.isTrashed && isOwner}
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
{/if}
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiVideoOutline}
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
/>
{/if}
<ActionMenuItem action={PlayOriginalVideo} />
{#if isOwner}
<hr />
<ActionMenuItem action={Actions.RefreshFacesJob} />
@@ -23,7 +23,7 @@
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { Icon, IconButton, Link, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
@@ -310,14 +310,13 @@
{#snippet popup({ marker })}
{@const { lat, lon } = marker}
<div class="flex flex-col items-center gap-1">
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
<a
<Text fontWeight="bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</Text>
<Link
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
target="_blank"
class="font-medium text-primary underline focus:outline-none"
class="text-primary"
>
{$t('open_in_openstreetmap')}
</a>
</Link>
</div>
{/snippet}
</Map>
@@ -324,6 +324,18 @@
shortcut: { key: ' ' },
onShortcut: () => (videoPlayer?.paused ? videoPlayer?.play() : videoPlayer?.pause()),
},
{
shortcut: { shift: true, key: 'ArrowLeft' },
onShortcut: () =>
videoPlayer ? (videoPlayer.currentTime = Math.max(videoPlayer.currentTime - 0.4, 0)) : undefined,
},
{
shortcut: { shift: true, key: 'ArrowRight' },
onShortcut: () =>
videoPlayer
? (videoPlayer.currentTime = Math.min(videoPlayer.currentTime + 0.4, videoPlayer.duration))
: undefined,
},
]}
/>
@@ -1,33 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mdiImageOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
album: AlbumResponseDto;
}
let { asset, album }: Props = $props();
const handleUpdateThumbnail = async () => {
try {
const response = await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumThumbnailAssetId: asset.id,
},
});
eventManager.emit('AlbumUpdate', response);
toastManager.primary($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}
};
</script>
<MenuOption text={$t('set_as_album_cover')} icon={mdiImageOutline} onClick={handleUpdateThumbnail} />
@@ -1,20 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import type { AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiAccountCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
}
let { asset }: Props = $props();
</script>
<MenuOption
icon={mdiAccountCircleOutline}
onClick={() => modalManager.show(ProfileImageCropperModal, { asset })}
text={$t('set_as_profile_picture')}
/>
@@ -342,7 +342,7 @@
{#if !!assetOwner}
<div class="absolute inset-e-2 bottom-1 z-2 max-w-[50%]">
<p class="max-w-full truncate text-xs font-medium text-white drop-shadow-lg">
<p class="text-white-shadow max-w-full truncate p-1 text-xs font-medium text-white">
{assetOwner.name}
</p>
</div>
@@ -38,7 +38,6 @@
Control,
ControlButton,
ControlGroup,
FullscreenControl,
GeoJSON,
GeolocateControl,
MapLibre,
@@ -343,7 +342,6 @@
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{/if}
@@ -401,13 +399,13 @@
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[calc(5px-50%)] text-primary" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="size-15 rounded-full border-2 border-immich-primary bg-immich-primary object-cover shadow-lg transition-all duration-200 hover:scale-150 hover:border-immich-dark-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
? $t('map_marker_for_image', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
@@ -415,7 +413,7 @@
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup?.({ marker: asMarker(feature) })}
{@render popup({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
+29 -1
View File
@@ -10,12 +10,13 @@ import {
updateAlbumUser,
type AlbumResponseDto,
type AlbumsAddAssetsResponseDto,
type AssetResponseDto,
type BulkIdResponseDto,
type UpdateAlbumDto,
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { mdiImageOutline, mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { authManager } from '$lib/managers/auth-manager.svelte';
@@ -68,6 +69,16 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
return { Share, AddUsers, CreateSharedLink };
};
export const getAlbumAssetActions = ($t: MessageFormatter, album: AlbumResponseDto, asset: AssetResponseDto) => {
const SetCover: ActionItem = {
title: $t('set_as_album_cover'),
icon: mdiImageOutline,
onAction: () => handleUpdateThumbnail(album, asset.id),
};
return { SetCover };
};
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {
const AddAssets: ActionItem = {
title: $t('add_assets'),
@@ -206,6 +217,23 @@ export const handleRemoveUserFromAlbum = async (album: AlbumResponseDto, albumUs
}
};
const handleUpdateThumbnail = async (album: AlbumResponseDto, assetId: string) => {
const $t = await getFormatter();
try {
const response = await updateAlbumInfo({
id: album.id,
updateAlbumDto: {
albumThumbnailAssetId: assetId,
},
});
eventManager.emit('AlbumUpdate', response);
toastManager.primary($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}
};
export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbumDto) => {
const $t = await getFormatter();
@@ -31,6 +31,12 @@ vitest.mock('$lib/utils', async () => {
};
});
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never,
};
});
describe('AssetService', () => {
describe('getAssetActions', () => {
beforeEach(() => {
+44 -1
View File
@@ -11,8 +11,10 @@ import {
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiAccountCircleOutline,
mdiAlertOutline,
mdiCogRefreshOutline,
mdiCompare,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDownload,
@@ -22,25 +24,32 @@ import {
mdiHeart,
mdiHeartOutline,
mdiImageRefreshOutline,
mdiImageSearch,
mdiInformationOutline,
mdiMagnifyMinusOutline,
mdiMagnifyPlusOutline,
mdiMotionPauseOutline,
mdiMotionPlayOutline,
mdiPlus,
mdiPresentationPlay,
mdiShareVariantOutline,
mdiTagPlusOutline,
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { ProjectionType } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { Route } from '$lib/route';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@@ -90,10 +99,11 @@ export const getAssetBulkActions = ($t: MessageFormatter) => {
return { AddToAlbum, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob };
};
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto & { stackPrimaryAssetId?: string }) => {
const sharedLink = getSharedLink();
const authUser = authManager.authenticated ? authManager.user : undefined;
const isOwner = !!(authUser && authUser.id === asset.ownerId);
const smartSearchEnabled = featureFlagsManager.value.smartSearch;
const Share: ActionItem = {
title: $t('share'),
@@ -140,6 +150,13 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
},
};
const PlaySlideshow: ActionItem = {
title: $t('slideshow'),
icon: mdiPresentationPlay,
$if: () => asset.visibility !== AssetVisibility.Locked,
onAction: () => slideshowStore.slideshowState.set(SlideshowState.PlaySlideshow),
};
const Favorite: ActionItem = {
title: $t('to_favorite'),
icon: mdiHeartOutline,
@@ -233,6 +250,28 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'e' }],
};
const SetProfilePicture: ActionItem = {
title: $t('set_as_profile_picture'),
icon: mdiAccountCircleOutline,
$if: () => asset.type === AssetTypeEnum.Image && asset.visibility !== AssetVisibility.Locked,
onAction: () => modalManager.show(ProfileImageCropperModal, { asset }),
};
const ViewInTimeline: ActionItem = {
title: $t('view_in_timeline'),
icon: mdiImageSearch,
$if: () => isOwner && asset.visibility !== AssetVisibility.Locked && !asset.isArchived && !asset.isTrashed,
onAction: () => goto(Route.photos({ at: asset.stackPrimaryAssetId ?? asset.id })),
};
const ViewSimilar: ActionItem = {
title: $t('view_similar_photos'),
icon: mdiCompare,
$if: () =>
asset.visibility !== AssetVisibility.Locked && !asset.isArchived && !asset.isTrashed && smartSearchEnabled,
onAction: () => goto(Route.search({ queryAssetId: asset.stackPrimaryAssetId ?? asset.id })),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
@@ -269,6 +308,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
PlaySlideshow,
AddToAlbum,
ZoomIn,
ZoomOut,
@@ -276,6 +316,9 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Tag,
TagPeople,
Edit,
SetProfilePicture,
ViewInTimeline,
ViewSimilar,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
+3 -2
View File
@@ -24,7 +24,8 @@ class FaceManager {
});
readonly people = $derived.by(() => {
const people = new SvelteMap<string, PersonResponseDto>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const people = new Map<string, PersonResponseDto>();
for (const face of this.data) {
if (face.person) {
@@ -32,7 +33,7 @@ class FaceManager {
}
}
return people.values();
return Array.from(people.values());
});
readonly facesByPersonId = $derived.by(() => {
+4 -1
View File
@@ -96,7 +96,10 @@ export type JSONSchemaProperty = {
array?: boolean;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
uiHint?: 'AlbumId' | 'AssetId' | 'PersonId';
uiHint?: {
type?: 'AlbumId' | 'AssetId' | 'PersonId';
order?: number;
};
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -169,7 +169,9 @@
preload={false}
/>
{#if person.name}
<span class="absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text">
<span
class="text-white-shadow absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text"
>
{person.name}
</span>
{/if}
@@ -55,7 +55,7 @@
);
const isGhost = $derived(step.id === 'ghost');
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint;
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint?.type;
const toIds = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : [String(value)]);
let dragImage = $state<Element>();
let isDropTarget = $state(false);