mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
feat(mobile): ios widgets (#19148)
* feat: working widgets * chore/feat: cleaned up API, added album picker to random widget * album filtering for requests * check album and throw if not found * fix app IDs and project configuration * switch to repository/service model for updating widgets * fix: remove home widget import * revert info.plist formatting changes * ran swift-format on widget code * more formatting changes (this time run from xcode) * show memory on widget picker snapshot * fix: dart changes from code review * fix: swift code review changes (not including task groups) * fix: use task groups to run image retrievals concurrently, get rid of do catch in favor of if let * chore: cleanup widget service in dart app * chore: format swift * fix: remove comma why does xcode not freak out over this >:( * switch to preview size for thumbnail * chore: cropped large image * fix: properly resize widgets so we dont OOM * fix: set app group on logout happens on first install * fix: stupid app ids * fix: revert back to thumbnail we are hitting OOM exceptions due to resizing, once we have on-the-fly resizing on server this can be upgraded * fix: more memory efficient resizing method, remove extraneous resize commands from API call * fix: random widget use 12 entries instead of 24 to save memory * fix: modify duration of entries to 20 minutes and only generate 10 at a time to avoid OOM * feat: toggle to show album name on random widget * Podfile lock --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
15c488ccd9
commit
a0f44f147b
@ -58,6 +58,8 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- geolocator_apple (1.2.0):
|
- geolocator_apple (1.2.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- home_widget (0.0.1):
|
||||||
|
- Flutter
|
||||||
- image_picker_ios (0.0.1):
|
- image_picker_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- integration_test (0.0.1):
|
- integration_test (0.0.1):
|
||||||
@ -144,6 +146,7 @@ DEPENDENCIES:
|
|||||||
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
|
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
|
||||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
|
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
|
||||||
|
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||||
@ -201,6 +204,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||||
geolocator_apple:
|
geolocator_apple:
|
||||||
:path: ".symlinks/plugins/geolocator_apple/ios"
|
:path: ".symlinks/plugins/geolocator_apple/ios"
|
||||||
|
home_widget:
|
||||||
|
:path: ".symlinks/plugins/home_widget/ios"
|
||||||
image_picker_ios:
|
image_picker_ios:
|
||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
integration_test:
|
integration_test:
|
||||||
@ -256,6 +261,7 @@ SPEC CHECKSUMS:
|
|||||||
flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80
|
flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80
|
||||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
||||||
geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff
|
geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff
|
||||||
|
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||||
isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26
|
isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26
|
||||||
|
@ -17,12 +17,23 @@
|
|||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
|
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* 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 */; };
|
||||||
|
F0B57D3C2DF764BD00DC5BCC /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */; };
|
||||||
|
F0B57D492DF764BE00DC5BCC /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
|
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
|
||||||
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
|
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
F0B57D472DF764BE00DC5BCC /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = F0B57D372DF764BD00DC5BCC;
|
||||||
|
remoteInfo = WidgetExtension;
|
||||||
|
};
|
||||||
FAC6F8982D287C890078CB2F /* PBXContainerItemProxy */ = {
|
FAC6F8982D287C890078CB2F /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
@ -49,6 +60,7 @@
|
|||||||
dstPath = "";
|
dstPath = "";
|
||||||
dstSubfolderSpec = 13;
|
dstSubfolderSpec = 13;
|
||||||
files = (
|
files = (
|
||||||
|
F0B57D492DF764BE00DC5BCC /* WidgetExtension.appex in Embed Foundation Extensions */,
|
||||||
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */,
|
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
@ -78,6 +90,9 @@
|
|||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
B1FBA9EE014DE20271B0FE77 /* 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>"; };
|
B1FBA9EE014DE20271B0FE77 /* 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>"; };
|
||||||
E0E99CDC17B3EB7FA8BA2332 /* 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>"; };
|
E0E99CDC17B3EB7FA8BA2332 /* 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>"; };
|
||||||
|
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
|
F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||||
F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
F7101BB0391A314774615E89 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
F8A35EA3C3E01BD66AFDE0E5 /* 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>"; };
|
F8A35EA3C3E01BD66AFDE0E5 /* 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>"; };
|
||||||
FA9973382CF6DF4B000EF859 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
FA9973382CF6DF4B000EF859 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
@ -89,6 +104,16 @@
|
|||||||
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
|
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
F0B57D4D2DF764BE00DC5BCC /* Exceptions for "WidgetExtension" folder in "WidgetExtension" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = F0B57D372DF764BD00DC5BCC /* WidgetExtension */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
@ -97,6 +122,14 @@
|
|||||||
path = Sync;
|
path = Sync;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
F0B57D4D2DF764BE00DC5BCC /* Exceptions for "WidgetExtension" folder in "WidgetExtension" target */,
|
||||||
|
);
|
||||||
|
path = WidgetExtension;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@ -108,6 +141,15 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
F0B57D352DF764BD00DC5BCC /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
F0B57D3C2DF764BD00DC5BCC /* SwiftUI.framework in Frameworks */,
|
||||||
|
F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
FAC6F88D2D287C890078CB2F /* Frameworks */ = {
|
FAC6F88D2D287C890078CB2F /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -137,6 +179,8 @@
|
|||||||
children = (
|
children = (
|
||||||
886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */,
|
886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */,
|
||||||
357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */,
|
357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */,
|
||||||
|
F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */,
|
||||||
|
F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -167,6 +211,7 @@
|
|||||||
9740EEB11CF90186004384FC /* Flutter */,
|
9740EEB11CF90186004384FC /* Flutter */,
|
||||||
97C146F01CF9000F007C117D /* Runner */,
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
FAC6F8B62D287F120078CB2F /* ShareExtension */,
|
FAC6F8B62D287F120078CB2F /* ShareExtension */,
|
||||||
|
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */,
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
0FB772A5B9601143383626CA /* Pods */,
|
0FB772A5B9601143383626CA /* Pods */,
|
||||||
1754452DD81DA6620E279E51 /* Frameworks */,
|
1754452DD81DA6620E279E51 /* Frameworks */,
|
||||||
@ -178,6 +223,7 @@
|
|||||||
children = (
|
children = (
|
||||||
97C146EE1CF9000F007C117D /* Immich-Debug.app */,
|
97C146EE1CF9000F007C117D /* Immich-Debug.app */,
|
||||||
FAC6F8902D287C890078CB2F /* ShareExtension.appex */,
|
FAC6F8902D287C890078CB2F /* ShareExtension.appex */,
|
||||||
|
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -234,6 +280,7 @@
|
|||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
FAC6F8992D287C890078CB2F /* PBXTargetDependency */,
|
FAC6F8992D287C890078CB2F /* PBXTargetDependency */,
|
||||||
|
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
@ -243,6 +290,26 @@
|
|||||||
productReference = 97C146EE1CF9000F007C117D /* Immich-Debug.app */;
|
productReference = 97C146EE1CF9000F007C117D /* Immich-Debug.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
F0B57D372DF764BD00DC5BCC /* WidgetExtension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = F0B57D4E2DF764BE00DC5BCC /* Build configuration list for PBXNativeTarget "WidgetExtension" */;
|
||||||
|
buildPhases = (
|
||||||
|
F0B57D342DF764BD00DC5BCC /* Sources */,
|
||||||
|
F0B57D352DF764BD00DC5BCC /* Frameworks */,
|
||||||
|
F0B57D362DF764BD00DC5BCC /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */,
|
||||||
|
);
|
||||||
|
name = WidgetExtension;
|
||||||
|
productName = WidgetExtension;
|
||||||
|
productReference = F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
FAC6F88F2D287C890078CB2F /* ShareExtension */ = {
|
FAC6F88F2D287C890078CB2F /* ShareExtension */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = FAC6F8A02D287C890078CB2F /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
buildConfigurationList = FAC6F8A02D287C890078CB2F /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
||||||
@ -268,7 +335,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastSwiftUpdateCheck = 1600;
|
LastSwiftUpdateCheck = 1640;
|
||||||
LastUpgradeCheck = 1510;
|
LastUpgradeCheck = 1510;
|
||||||
ORGANIZATIONNAME = "";
|
ORGANIZATIONNAME = "";
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
@ -277,6 +344,9 @@
|
|||||||
LastSwiftMigration = 1100;
|
LastSwiftMigration = 1100;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
|
F0B57D372DF764BD00DC5BCC = {
|
||||||
|
CreatedOnToolsVersion = 16.4;
|
||||||
|
};
|
||||||
FAC6F88F2D287C890078CB2F = {
|
FAC6F88F2D287C890078CB2F = {
|
||||||
CreatedOnToolsVersion = 16.0;
|
CreatedOnToolsVersion = 16.0;
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
@ -298,6 +368,7 @@
|
|||||||
targets = (
|
targets = (
|
||||||
97C146ED1CF9000F007C117D /* Runner */,
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
FAC6F88F2D287C890078CB2F /* ShareExtension */,
|
FAC6F88F2D287C890078CB2F /* ShareExtension */,
|
||||||
|
F0B57D372DF764BD00DC5BCC /* WidgetExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@ -314,6 +385,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
F0B57D362DF764BD00DC5BCC /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
FAC6F88E2D287C890078CB2F /* Resources */ = {
|
FAC6F88E2D287C890078CB2F /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -448,6 +527,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
F0B57D342DF764BD00DC5BCC /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
FAC6F88C2D287C890078CB2F /* Sources */ = {
|
FAC6F88C2D287C890078CB2F /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@ -459,6 +545,11 @@
|
|||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
|
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = F0B57D372DF764BD00DC5BCC /* WidgetExtension */;
|
||||||
|
targetProxy = F0B57D472DF764BE00DC5BCC /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
FAC6F8992D287C890078CB2F /* PBXTargetDependency */ = {
|
FAC6F8992D287C890078CB2F /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = FAC6F88F2D287C890078CB2F /* ShareExtension */;
|
target = FAC6F88F2D287C890078CB2F /* ShareExtension */;
|
||||||
@ -751,6 +842,129 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
F0B57D4A2DF764BE00DC5BCC /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = WidgetExtension/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Widget;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.Widget;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
F0B57D4B2DF764BE00DC5BCC /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = WidgetExtension/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Widget;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.Widget;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
F0B57D4C2DF764BE00DC5BCC /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = WidgetExtension/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Widget;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.Widget;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
FAC6F89C2D287C890078CB2F /* Debug */ = {
|
FAC6F89C2D287C890078CB2F /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */;
|
baseConfigurationReference = F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */;
|
||||||
@ -900,6 +1114,16 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
F0B57D4E2DF764BE00DC5BCC /* Build configuration list for PBXNativeTarget "WidgetExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
F0B57D4A2DF764BE00DC5BCC /* Debug */,
|
||||||
|
F0B57D4B2DF764BE00DC5BCC /* Release */,
|
||||||
|
F0B57D4C2DF764BE00DC5BCC /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
FAC6F8A02D287C890078CB2F /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
FAC6F8A02D287C890078CB2F /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
6
mobile/ios/WidgetExtension/Assets.xcassets/Contents.json
Normal file
6
mobile/ios/WidgetExtension/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
58
mobile/ios/WidgetExtension/EntryGenerators.swift
Normal file
58
mobile/ios/WidgetExtension/EntryGenerators.swift
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
func buildEntry(
|
||||||
|
api: ImmichAPI,
|
||||||
|
asset: SearchResult,
|
||||||
|
dateOffset: Int,
|
||||||
|
subtitle: String? = nil
|
||||||
|
)
|
||||||
|
async throws -> ImageEntry
|
||||||
|
{
|
||||||
|
let entryDate = Calendar.current.date(
|
||||||
|
byAdding: .minute,
|
||||||
|
value: dateOffset * 20,
|
||||||
|
to: Date.now
|
||||||
|
)!
|
||||||
|
let image = try await api.fetchImage(asset: asset)
|
||||||
|
return ImageEntry(date: entryDate, image: image, subtitle: subtitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandomEntries(
|
||||||
|
api: ImmichAPI,
|
||||||
|
now: Date,
|
||||||
|
count: Int,
|
||||||
|
albumId: String? = nil,
|
||||||
|
subtitle: String? = nil
|
||||||
|
)
|
||||||
|
async throws -> [ImageEntry]
|
||||||
|
{
|
||||||
|
|
||||||
|
var entries: [ImageEntry] = []
|
||||||
|
let albumIds = albumId != nil ? [albumId!] : []
|
||||||
|
|
||||||
|
let randomAssets = try await api.fetchSearchResults(
|
||||||
|
with: SearchFilters(size: count, albumIds: albumIds)
|
||||||
|
)
|
||||||
|
|
||||||
|
await withTaskGroup(of: ImageEntry?.self) { group in
|
||||||
|
for (dateOffset, asset) in randomAssets.enumerated() {
|
||||||
|
group.addTask {
|
||||||
|
return try? await buildEntry(
|
||||||
|
api: api,
|
||||||
|
asset: asset,
|
||||||
|
dateOffset: dateOffset,
|
||||||
|
subtitle: subtitle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await result in group {
|
||||||
|
if let entry = result {
|
||||||
|
entries.append(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
90
mobile/ios/WidgetExtension/ImageWidgetView.swift
Normal file
90
mobile/ios/WidgetExtension/ImageWidgetView.swift
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct ImageEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
var image: UIImage?
|
||||||
|
var subtitle: String? = nil
|
||||||
|
var error: WidgetError? = nil
|
||||||
|
|
||||||
|
// Resizes the stored image to a maximum width of 450 pixels
|
||||||
|
mutating func resize() {
|
||||||
|
if (image == nil || image!.size.height < 450 || image!.size.width < 450 ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
image = image?.resized(toWidth: 450)
|
||||||
|
|
||||||
|
if image == nil {
|
||||||
|
error = .unableToResize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ImmichWidgetView: View {
|
||||||
|
var entry: ImageEntry
|
||||||
|
|
||||||
|
func getErrorText(_ error: WidgetError?) -> String {
|
||||||
|
switch error {
|
||||||
|
case .noLogin:
|
||||||
|
return "Login to Immich"
|
||||||
|
|
||||||
|
case .fetchFailed:
|
||||||
|
return "Unable to connect to your Immich instance"
|
||||||
|
|
||||||
|
case .albumNotFound:
|
||||||
|
return "Album not found"
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "An unknown error occured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if entry.image == nil {
|
||||||
|
VStack {
|
||||||
|
Image("LaunchImage")
|
||||||
|
Text(getErrorText(entry.error))
|
||||||
|
.minimumScaleFactor(0.25)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
} else {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Color.clear.overlay(
|
||||||
|
Image(uiImage: entry.image!)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
)
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
if let subtitle = entry.subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.black.opacity(0.6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview(
|
||||||
|
as: .systemMedium,
|
||||||
|
widget: {
|
||||||
|
ImmichRandomWidget()
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
let date = Date()
|
||||||
|
ImageEntry(
|
||||||
|
date: date,
|
||||||
|
image: UIImage(named: "ImmichLogo"),
|
||||||
|
subtitle: "1 year ago"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
219
mobile/ios/WidgetExtension/ImmichAPI.swift
Normal file
219
mobile/ios/WidgetExtension/ImmichAPI.swift
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
enum WidgetError: Error {
|
||||||
|
case noLogin
|
||||||
|
case fetchFailed
|
||||||
|
case unknown
|
||||||
|
case albumNotFound
|
||||||
|
case unableToResize
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AssetType: String, Codable {
|
||||||
|
case image = "IMAGE"
|
||||||
|
case video = "VIDEO"
|
||||||
|
case audio = "AUDIO"
|
||||||
|
case other = "OTHER"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchResult: Codable {
|
||||||
|
let id: String
|
||||||
|
let type: AssetType
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchFilters: Codable {
|
||||||
|
var type: AssetType = .image
|
||||||
|
let size: Int
|
||||||
|
var albumIds: [String] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MemoryResult: Codable {
|
||||||
|
let id: String
|
||||||
|
var assets: [SearchResult]
|
||||||
|
let type: String
|
||||||
|
|
||||||
|
struct MemoryData: Codable {
|
||||||
|
let year: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: MemoryData
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Album: Codable {
|
||||||
|
let id: String
|
||||||
|
let albumName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: API
|
||||||
|
|
||||||
|
class ImmichAPI {
|
||||||
|
struct ServerConfig {
|
||||||
|
let serverEndpoint: String
|
||||||
|
let sessionKey: String
|
||||||
|
}
|
||||||
|
let serverConfig: ServerConfig
|
||||||
|
|
||||||
|
init() async throws {
|
||||||
|
// fetch the credentials from the UserDefaults store that dart placed here
|
||||||
|
guard let defaults = UserDefaults(suiteName: "group.app.immich.share"),
|
||||||
|
let serverURL = defaults.string(forKey: "widget_server_url"),
|
||||||
|
let sessionKey = defaults.string(forKey: "widget_auth_token")
|
||||||
|
else {
|
||||||
|
throw WidgetError.noLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
if serverURL == "" || sessionKey == "" {
|
||||||
|
throw WidgetError.noLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
serverConfig = ServerConfig(
|
||||||
|
serverEndpoint: serverURL,
|
||||||
|
sessionKey: sessionKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildRequestURL(
|
||||||
|
serverConfig: ServerConfig,
|
||||||
|
endpoint: String,
|
||||||
|
params: [URLQueryItem] = []
|
||||||
|
) -> URL? {
|
||||||
|
guard let baseURL = URL(string: serverConfig.serverEndpoint) else {
|
||||||
|
fatalError("Invalid base URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine the base URL and API path
|
||||||
|
let fullPath = baseURL.appendingPathComponent(
|
||||||
|
endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add the session key as a query parameter
|
||||||
|
var components = URLComponents(
|
||||||
|
url: fullPath,
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
)
|
||||||
|
components?.queryItems = [
|
||||||
|
URLQueryItem(name: "sessionKey", value: serverConfig.sessionKey)
|
||||||
|
]
|
||||||
|
components?.queryItems?.append(contentsOf: params)
|
||||||
|
|
||||||
|
return components?.url
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSearchResults(with filters: SearchFilters) async throws
|
||||||
|
-> [SearchResult]
|
||||||
|
{
|
||||||
|
// get URL
|
||||||
|
guard
|
||||||
|
let searchURL = buildRequestURL(
|
||||||
|
serverConfig: serverConfig,
|
||||||
|
endpoint: "/search/random"
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: searchURL)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.httpBody = try JSONEncoder().encode(filters)
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
// decode data
|
||||||
|
return try JSONDecoder().decode([SearchResult].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
|
||||||
|
// get URL
|
||||||
|
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
|
||||||
|
guard
|
||||||
|
let searchURL = buildRequestURL(
|
||||||
|
serverConfig: serverConfig,
|
||||||
|
endpoint: "/memories",
|
||||||
|
params: memoryParams
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: searchURL)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
// decode data
|
||||||
|
return try JSONDecoder().decode([MemoryResult].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchImage(asset: SearchResult) async throws -> UIImage {
|
||||||
|
let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
|
||||||
|
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
|
||||||
|
|
||||||
|
guard
|
||||||
|
let fetchURL = buildRequestURL(
|
||||||
|
serverConfig: serverConfig,
|
||||||
|
endpoint: assetEndpoint,
|
||||||
|
params: thumbnailParams
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: fetchURL)
|
||||||
|
|
||||||
|
guard let img = UIImage(data: data) else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAlbums() async throws -> [Album] {
|
||||||
|
// get URL
|
||||||
|
guard
|
||||||
|
let searchURL = buildRequestURL(
|
||||||
|
serverConfig: serverConfig,
|
||||||
|
endpoint: "/albums"
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: searchURL)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
// decode data
|
||||||
|
return try JSONDecoder().decode([Album].self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need a shared cache for albums to efficiently handle the album picker queries
|
||||||
|
actor AlbumCache {
|
||||||
|
static let shared = AlbumCache()
|
||||||
|
|
||||||
|
private var api: ImmichAPI? = nil
|
||||||
|
private var albums: [Album]? = nil
|
||||||
|
|
||||||
|
func getAlbums(refresh: Bool = false) async throws -> [Album] {
|
||||||
|
// Check the API before we try to show cached albums
|
||||||
|
// Sometimes iOS caches this object and keeps it around
|
||||||
|
// even after nuking the timeline
|
||||||
|
|
||||||
|
api = try? await ImmichAPI()
|
||||||
|
|
||||||
|
guard api != nil else {
|
||||||
|
throw WidgetError.noLogin
|
||||||
|
}
|
||||||
|
|
||||||
|
if let albums, !refresh {
|
||||||
|
return albums
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetched = try await api!.fetchAlbums()
|
||||||
|
albums = fetched
|
||||||
|
return fetched
|
||||||
|
}
|
||||||
|
}
|
11
mobile/ios/WidgetExtension/Info.plist
Normal file
11
mobile/ios/WidgetExtension/Info.plist
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?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>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
20
mobile/ios/WidgetExtension/UIImage+Resize.swift
Normal file
20
mobile/ios/WidgetExtension/UIImage+Resize.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// Utils.swift
|
||||||
|
// Runner
|
||||||
|
//
|
||||||
|
// Created by Alex Tran and Brandon Wees on 6/16/25.
|
||||||
|
//
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIImage {
|
||||||
|
/// Crops the image to ensure width and height do not exceed maxSize.
|
||||||
|
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
|
||||||
|
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
|
||||||
|
let canvas = CGSize(width: width, height: CGFloat(ceil(width/size.width * size.height)))
|
||||||
|
let format = imageRendererFormat
|
||||||
|
format.opaque = isOpaque
|
||||||
|
return UIGraphicsImageRenderer(size: canvas, format: format).image {
|
||||||
|
_ in draw(in: CGRect(origin: .zero, size: canvas))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
mobile/ios/WidgetExtension/WidgetBundle.swift
Normal file
10
mobile/ios/WidgetExtension/WidgetBundle.swift
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct ImmichWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
ImmichRandomWidget()
|
||||||
|
ImmichMemoryWidget()
|
||||||
|
}
|
||||||
|
}
|
10
mobile/ios/WidgetExtension/WidgetExtension.entitlements
Normal file
10
mobile/ios/WidgetExtension/WidgetExtension.entitlements
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?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>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.app.immich.share</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
166
mobile/ios/WidgetExtension/widgets/MemoryWidget.swift
Normal file
166
mobile/ios/WidgetExtension/widgets/MemoryWidget.swift
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import AppIntents
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct ImmichMemoryProvider: TimelineProvider {
|
||||||
|
func getYearDifferenceSubtitle(assetYear: Int) -> String {
|
||||||
|
let currentYear = Calendar.current.component(.year, from: Date.now)
|
||||||
|
// construct a "X years ago" subtitle
|
||||||
|
let yearDifference = currentYear - assetYear
|
||||||
|
|
||||||
|
return "\(yearDifference) year\(yearDifference == 1 ? "" : "s") ago"
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholder(in context: Context) -> ImageEntry {
|
||||||
|
ImageEntry(date: Date(), image: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(
|
||||||
|
in context: Context,
|
||||||
|
completion: @escaping @Sendable (ImageEntry) -> Void
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
guard let api = try? await ImmichAPI() else {
|
||||||
|
completion(ImageEntry(date: Date(), image: nil, error: .noLogin))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let memories = try? await api.fetchMemory(for: Date.now)
|
||||||
|
else {
|
||||||
|
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for memory in memories {
|
||||||
|
if let asset = memory.assets.first(where: { $0.type == .image }),
|
||||||
|
var entry = try? await buildEntry(
|
||||||
|
api: api,
|
||||||
|
asset: asset,
|
||||||
|
dateOffset: 0,
|
||||||
|
subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
entry.resize()
|
||||||
|
completion(entry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback to random image
|
||||||
|
guard
|
||||||
|
let randomImage = try? await api.fetchSearchResults(
|
||||||
|
with: SearchFilters(size: 1)
|
||||||
|
).first
|
||||||
|
else {
|
||||||
|
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
var imageEntry = try? await buildEntry(
|
||||||
|
api: api,
|
||||||
|
asset: randomImage,
|
||||||
|
dateOffset: 0
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imageEntry.resize()
|
||||||
|
completion(imageEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(
|
||||||
|
in context: Context,
|
||||||
|
completion: @escaping @Sendable (Timeline<ImageEntry>) -> Void
|
||||||
|
) {
|
||||||
|
Task {
|
||||||
|
var entries: [ImageEntry] = []
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
guard let api = try? await ImmichAPI() else {
|
||||||
|
entries.append(ImageEntry(date: now, image: nil, error: .noLogin))
|
||||||
|
completion(Timeline(entries: entries, policy: .atEnd))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let memories = try await api.fetchMemory(for: Date.now)
|
||||||
|
|
||||||
|
await withTaskGroup(of: ImageEntry?.self) { group in
|
||||||
|
var totalAssets = 0
|
||||||
|
|
||||||
|
for memory in memories {
|
||||||
|
for asset in memory.assets {
|
||||||
|
if asset.type == .image && totalAssets < 12 {
|
||||||
|
group.addTask {
|
||||||
|
try? await buildEntry(
|
||||||
|
api: api,
|
||||||
|
asset: asset,
|
||||||
|
dateOffset: totalAssets,
|
||||||
|
subtitle: getYearDifferenceSubtitle(
|
||||||
|
assetYear: memory.data.year
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalAssets += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await result in group {
|
||||||
|
if let entry = result {
|
||||||
|
entries.append(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we didnt add any memory images (some failure occured or no images in memory),
|
||||||
|
// default to 12 hours of random photos
|
||||||
|
if entries.count == 0 {
|
||||||
|
entries.append(
|
||||||
|
contentsOf: (try? await generateRandomEntries(
|
||||||
|
api: api,
|
||||||
|
now: now,
|
||||||
|
count: 12
|
||||||
|
)) ?? []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we fail to fetch images, we still want to add an entry
|
||||||
|
// with a nil image and an error
|
||||||
|
if entries.count == 0 {
|
||||||
|
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize all images to something that can be stored by iOS
|
||||||
|
for i in entries.indices {
|
||||||
|
entries[i].resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(Timeline(entries: entries, policy: .atEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ImmichMemoryWidget: Widget {
|
||||||
|
let kind: String = "com.immich.widget.memory"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
provider: ImmichMemoryProvider()
|
||||||
|
) { entry in
|
||||||
|
ImmichWidgetView(entry: entry)
|
||||||
|
.containerBackground(.regularMaterial, for: .widget)
|
||||||
|
}
|
||||||
|
// allow image to take up entire widget
|
||||||
|
.contentMarginsDisabled()
|
||||||
|
|
||||||
|
// widget picker info
|
||||||
|
.configurationDisplayName("Memories")
|
||||||
|
.description("See memories from Immich.")
|
||||||
|
}
|
||||||
|
}
|
170
mobile/ios/WidgetExtension/widgets/RandomWidget.swift
Normal file
170
mobile/ios/WidgetExtension/widgets/RandomWidget.swift
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import AppIntents
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
// MARK: Widget Configuration
|
||||||
|
|
||||||
|
extension Album: @unchecked Sendable, AppEntity, Identifiable {
|
||||||
|
|
||||||
|
struct AlbumQuery: EntityQuery {
|
||||||
|
func entities(for identifiers: [Album.ID]) async throws -> [Album] {
|
||||||
|
// use cached albums to search
|
||||||
|
var albums = (try? await AlbumCache.shared.getAlbums()) ?? []
|
||||||
|
albums.insert(NO_ALBUM, at: 0)
|
||||||
|
|
||||||
|
return albums.filter {
|
||||||
|
identifiers.contains($0.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestedEntities() async throws -> [Album] {
|
||||||
|
var albums = (try? await AlbumCache.shared.getAlbums(refresh: true)) ?? []
|
||||||
|
albums.insert(NO_ALBUM, at: 0)
|
||||||
|
|
||||||
|
return albums
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var defaultQuery = AlbumQuery()
|
||||||
|
static var typeDisplayRepresentation = TypeDisplayRepresentation(
|
||||||
|
name: "Album"
|
||||||
|
)
|
||||||
|
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
DisplayRepresentation(title: "\(albumName)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let NO_ALBUM = Album(id: "NONE", albumName: "None")
|
||||||
|
|
||||||
|
struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
|
||||||
|
static var title: LocalizedStringResource { "Select Album" }
|
||||||
|
static var description: IntentDescription {
|
||||||
|
"Choose an album to show images from"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parameter(title: "Album", default: NO_ALBUM)
|
||||||
|
var album: Album?
|
||||||
|
|
||||||
|
@Parameter(title: "Show Album Name", default: false)
|
||||||
|
var showAlbumName: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Provider
|
||||||
|
|
||||||
|
struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> ImageEntry {
|
||||||
|
ImageEntry(date: Date(), image: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(
|
||||||
|
for configuration: RandomConfigurationAppIntent,
|
||||||
|
in context: Context
|
||||||
|
) async
|
||||||
|
-> ImageEntry
|
||||||
|
{
|
||||||
|
guard let api = try? await ImmichAPI() else {
|
||||||
|
return ImageEntry(date: Date(), image: nil, error: .noLogin)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let randomImage = try? await api.fetchSearchResults(
|
||||||
|
with: SearchFilters(size: 1)
|
||||||
|
).first
|
||||||
|
else {
|
||||||
|
return ImageEntry(date: Date(), image: nil, error: .fetchFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
var entry = try? await buildEntry(
|
||||||
|
api: api,
|
||||||
|
asset: randomImage,
|
||||||
|
dateOffset: 0
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
return ImageEntry(date: Date(), image: nil, error: .fetchFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.resize()
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(
|
||||||
|
for configuration: RandomConfigurationAppIntent,
|
||||||
|
in context: Context
|
||||||
|
) async
|
||||||
|
-> Timeline<ImageEntry>
|
||||||
|
{
|
||||||
|
var entries: [ImageEntry] = []
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
// If we don't have a server config, return an entry with an error
|
||||||
|
guard let api = try? await ImmichAPI() else {
|
||||||
|
entries.append(ImageEntry(date: now, image: nil, error: .noLogin))
|
||||||
|
return Timeline(entries: entries, policy: .atEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nil if album is NONE or nil
|
||||||
|
let albumId =
|
||||||
|
configuration.album?.id != "NONE" ? configuration.album?.id : nil
|
||||||
|
var albumName: String? = albumId != nil ? configuration.album?.albumName : nil
|
||||||
|
|
||||||
|
if albumId != nil {
|
||||||
|
// make sure the album exists on server, otherwise show error
|
||||||
|
guard let albums = try? await api.fetchAlbums() else {
|
||||||
|
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
|
||||||
|
return Timeline(entries: entries, policy: .atEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !albums.contains(where: { $0.id == albumId }) {
|
||||||
|
entries.append(ImageEntry(date: now, image: nil, error: .albumNotFound))
|
||||||
|
return Timeline(entries: entries, policy: .atEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.append(
|
||||||
|
contentsOf: (try? await generateRandomEntries(
|
||||||
|
api: api,
|
||||||
|
now: now,
|
||||||
|
count: 12,
|
||||||
|
albumId: albumId,
|
||||||
|
subtitle: configuration.showAlbumName ? albumName : nil
|
||||||
|
))
|
||||||
|
?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
// If we fail to fetch images, we still want to add an entry with a nil image and an error
|
||||||
|
if entries.count == 0 {
|
||||||
|
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize all images to something that can be stored by iOS
|
||||||
|
for i in entries.indices {
|
||||||
|
entries[i].resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Timeline(entries: entries, policy: .atEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ImmichRandomWidget: Widget {
|
||||||
|
let kind: String = "com.immich.widget.random"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
AppIntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: RandomConfigurationAppIntent.self,
|
||||||
|
provider: ImmichRandomProvider()
|
||||||
|
) { entry in
|
||||||
|
ImmichWidgetView(entry: entry)
|
||||||
|
.containerBackground(.regularMaterial, for: .widget)
|
||||||
|
}
|
||||||
|
// allow image to take up entire widget
|
||||||
|
.contentMarginsDisabled()
|
||||||
|
|
||||||
|
// widget picker info
|
||||||
|
.configurationDisplayName("Random")
|
||||||
|
.description("View a random image from your library or a specific album.")
|
||||||
|
}
|
||||||
|
}
|
@ -20,3 +20,15 @@ const String kSecuredPinCode = "secured_pin_code";
|
|||||||
const int kTimelineNoneSegmentSize = 120;
|
const int kTimelineNoneSegmentSize = 120;
|
||||||
const int kTimelineAssetLoadBatchSize = 256;
|
const int kTimelineAssetLoadBatchSize = 256;
|
||||||
const int kTimelineAssetLoadOppositeSize = 64;
|
const int kTimelineAssetLoadOppositeSize = 64;
|
||||||
|
|
||||||
|
// Widget keys
|
||||||
|
const String kWidgetAuthToken = "widget_auth_token";
|
||||||
|
const String kWidgetServerEndpoint = "widget_server_url";
|
||||||
|
const String appShareGroupId = "group.app.immich.share";
|
||||||
|
|
||||||
|
// add widget identifiers here for new widgets
|
||||||
|
// these are used to force a widget refresh
|
||||||
|
const List<String> kWidgetNames = [
|
||||||
|
'com.immich.widget.random',
|
||||||
|
'com.immich.widget.memory',
|
||||||
|
];
|
||||||
|
5
mobile/lib/interfaces/widget.interface.dart
Normal file
5
mobile/lib/interfaces/widget.interface.dart
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
abstract interface class IWidgetRepository {
|
||||||
|
Future<void> saveData(String key, String value);
|
||||||
|
Future<void> refresh(String name);
|
||||||
|
Future<void> setAppGroupId(String appGroupId);
|
||||||
|
}
|
@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
|||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/auth.service.dart';
|
import 'package:immich_mobile/services/auth.service.dart';
|
||||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||||
|
import 'package:immich_mobile/services/widget.service.dart';
|
||||||
import 'package:immich_mobile/utils/hash.dart';
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
@ -23,6 +24,7 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
|||||||
ref.watch(apiServiceProvider),
|
ref.watch(apiServiceProvider),
|
||||||
ref.watch(userServiceProvider),
|
ref.watch(userServiceProvider),
|
||||||
ref.watch(secureStorageServiceProvider),
|
ref.watch(secureStorageServiceProvider),
|
||||||
|
ref.watch(widgetServiceProvider),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -31,6 +33,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
final ApiService _apiService;
|
final ApiService _apiService;
|
||||||
final UserService _userService;
|
final UserService _userService;
|
||||||
final SecureStorageService _secureStorageService;
|
final SecureStorageService _secureStorageService;
|
||||||
|
final WidgetService _widgetService;
|
||||||
final _log = Logger("AuthenticationNotifier");
|
final _log = Logger("AuthenticationNotifier");
|
||||||
|
|
||||||
static const Duration _timeoutDuration = Duration(seconds: 7);
|
static const Duration _timeoutDuration = Duration(seconds: 7);
|
||||||
@ -40,6 +43,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
this._apiService,
|
this._apiService,
|
||||||
this._userService,
|
this._userService,
|
||||||
this._secureStorageService,
|
this._secureStorageService,
|
||||||
|
this._widgetService,
|
||||||
) : super(
|
) : super(
|
||||||
AuthState(
|
AuthState(
|
||||||
deviceId: "",
|
deviceId: "",
|
||||||
@ -76,6 +80,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
try {
|
try {
|
||||||
await _secureStorageService.delete(kSecuredPinCode);
|
await _secureStorageService.delete(kSecuredPinCode);
|
||||||
|
await _widgetService.clearCredentials();
|
||||||
|
|
||||||
await _authService.logout();
|
await _authService.logout();
|
||||||
} finally {
|
} finally {
|
||||||
await _cleanUp();
|
await _cleanUp();
|
||||||
@ -112,6 +118,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
}) async {
|
}) async {
|
||||||
await _apiService.setAccessToken(accessToken);
|
await _apiService.setAccessToken(accessToken);
|
||||||
|
|
||||||
|
await _widgetService.writeCredentials(
|
||||||
|
Store.get(StoreKey.serverEndpoint),
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
|
||||||
// Get the deviceid from the store if it exists, otherwise generate a new one
|
// Get the deviceid from the store if it exists, otherwise generate a new one
|
||||||
String deviceId =
|
String deviceId =
|
||||||
Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
|
Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
|
||||||
|
24
mobile/lib/repositories/widget.repository.dart
Normal file
24
mobile/lib/repositories/widget.repository.dart
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import 'package:home_widget/home_widget.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/widget.interface.dart';
|
||||||
|
|
||||||
|
final widgetRepositoryProvider = Provider((_) => WidgetRepository());
|
||||||
|
|
||||||
|
class WidgetRepository implements IWidgetRepository {
|
||||||
|
WidgetRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveData(String key, String value) async {
|
||||||
|
await HomeWidget.saveWidgetData<String>(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> refresh(String name) async {
|
||||||
|
await HomeWidget.updateWidget(name: name, iOSName: name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setAppGroupId(String appGroupId) async {
|
||||||
|
await HomeWidget.setAppGroupId(appGroupId);
|
||||||
|
}
|
||||||
|
}
|
40
mobile/lib/services/widget.service.dart
Normal file
40
mobile/lib/services/widget.service.dart
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/constants.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/widget.interface.dart';
|
||||||
|
import 'package:immich_mobile/repositories/widget.repository.dart';
|
||||||
|
|
||||||
|
final widgetServiceProvider = Provider((ref) {
|
||||||
|
return WidgetService(
|
||||||
|
ref.watch(widgetRepositoryProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
class WidgetService {
|
||||||
|
final IWidgetRepository _repository;
|
||||||
|
|
||||||
|
WidgetService(this._repository);
|
||||||
|
|
||||||
|
Future<void> writeCredentials(String serverURL, String sessionKey) async {
|
||||||
|
await _repository.setAppGroupId(appShareGroupId);
|
||||||
|
await _repository.saveData(kWidgetServerEndpoint, serverURL);
|
||||||
|
await _repository.saveData(kWidgetAuthToken, sessionKey);
|
||||||
|
|
||||||
|
// wait 3 seconds to ensure the widget is updated, dont block
|
||||||
|
Future.delayed(const Duration(seconds: 3), refreshWidgets);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearCredentials() async {
|
||||||
|
await _repository.setAppGroupId(appShareGroupId);
|
||||||
|
await _repository.saveData(kWidgetServerEndpoint, "");
|
||||||
|
await _repository.saveData(kWidgetAuthToken, "");
|
||||||
|
|
||||||
|
// wait 3 seconds to ensure the widget is updated, dont block
|
||||||
|
Future.delayed(const Duration(seconds: 3), refreshWidgets);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshWidgets() async {
|
||||||
|
for (final name in kWidgetNames) {
|
||||||
|
await _repository.refresh(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -863,6 +863,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
|
home_widget:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: home_widget
|
||||||
|
sha256: ad9634ef5894f3bac73f04d59e2e5151a39798f49985399fd928dadc828d974a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.0"
|
||||||
hooks_riverpod:
|
hooks_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -40,6 +40,7 @@ dependencies:
|
|||||||
fluttertoast: ^8.2.12
|
fluttertoast: ^8.2.12
|
||||||
geolocator: ^14.0.0
|
geolocator: ^14.0.0
|
||||||
hooks_riverpod: ^2.6.1
|
hooks_riverpod: ^2.6.1
|
||||||
|
home_widget: ^0.8.0
|
||||||
http: ^1.3.0
|
http: ^1.3.0
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
|
Loading…
x
Reference in New Issue
Block a user