From a0f44f147bda2383bf61254b2090f15533335924 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 17 Jun 2025 09:43:09 -0500 Subject: [PATCH] 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 --- mobile/ios/Podfile.lock | 6 + mobile/ios/Runner.xcodeproj/project.pbxproj | 226 +++++++++++++++++- .../Assets.xcassets/Contents.json | 6 + .../ios/WidgetExtension/EntryGenerators.swift | 58 +++++ .../ios/WidgetExtension/ImageWidgetView.swift | 90 +++++++ mobile/ios/WidgetExtension/ImmichAPI.swift | 219 +++++++++++++++++ mobile/ios/WidgetExtension/Info.plist | 11 + .../ios/WidgetExtension/UIImage+Resize.swift | 20 ++ mobile/ios/WidgetExtension/WidgetBundle.swift | 10 + .../WidgetExtension.entitlements | 10 + .../widgets/MemoryWidget.swift | 166 +++++++++++++ .../widgets/RandomWidget.swift | 170 +++++++++++++ mobile/lib/constants/constants.dart | 12 + mobile/lib/interfaces/widget.interface.dart | 5 + mobile/lib/providers/auth.provider.dart | 11 + .../lib/repositories/widget.repository.dart | 24 ++ mobile/lib/services/widget.service.dart | 40 ++++ mobile/pubspec.lock | 8 + mobile/pubspec.yaml | 1 + 19 files changed, 1092 insertions(+), 1 deletion(-) create mode 100644 mobile/ios/WidgetExtension/Assets.xcassets/Contents.json create mode 100644 mobile/ios/WidgetExtension/EntryGenerators.swift create mode 100644 mobile/ios/WidgetExtension/ImageWidgetView.swift create mode 100644 mobile/ios/WidgetExtension/ImmichAPI.swift create mode 100644 mobile/ios/WidgetExtension/Info.plist create mode 100644 mobile/ios/WidgetExtension/UIImage+Resize.swift create mode 100644 mobile/ios/WidgetExtension/WidgetBundle.swift create mode 100644 mobile/ios/WidgetExtension/WidgetExtension.entitlements create mode 100644 mobile/ios/WidgetExtension/widgets/MemoryWidget.swift create mode 100644 mobile/ios/WidgetExtension/widgets/RandomWidget.swift create mode 100644 mobile/lib/interfaces/widget.interface.dart create mode 100644 mobile/lib/repositories/widget.repository.dart create mode 100644 mobile/lib/services/widget.service.dart diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 36ec03dd0e..09bd36022b 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -58,6 +58,8 @@ PODS: - Flutter - geolocator_apple (1.2.0): - Flutter + - home_widget (0.0.1): + - Flutter - image_picker_ios (0.0.1): - Flutter - integration_test (0.0.1): @@ -144,6 +146,7 @@ DEPENDENCIES: - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/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`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) @@ -201,6 +204,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/fluttertoast/ios" geolocator_apple: :path: ".symlinks/plugins/geolocator_apple/ios" + home_widget: + :path: ".symlinks/plugins/home_widget/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: @@ -256,6 +261,7 @@ SPEC CHECKSUMS: flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff + home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 3cbbf83f01..5cd040be79 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -17,12 +17,23 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 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, ); }; }; FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; }; FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + F0B57D472DF764BE00DC5BCC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = F0B57D372DF764BD00DC5BCC; + remoteInfo = WidgetExtension; + }; FAC6F8982D287C890078CB2F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -49,6 +60,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + F0B57D492DF764BE00DC5BCC /* WidgetExtension.appex in Embed Foundation Extensions */, FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; @@ -78,6 +90,9 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; + 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 = ""; }; 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 = ""; }; FA9973382CF6DF4B000EF859 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; @@ -89,6 +104,16 @@ FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; /* 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 */ B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; @@ -97,6 +122,14 @@ path = Sync; sourceTree = ""; }; + F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + F0B57D4D2DF764BE00DC5BCC /* Exceptions for "WidgetExtension" folder in "WidgetExtension" target */, + ); + path = WidgetExtension; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -108,6 +141,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F0B57D352DF764BD00DC5BCC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F0B57D3C2DF764BD00DC5BCC /* SwiftUI.framework in Frameworks */, + F0B57D3A2DF764BD00DC5BCC /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FAC6F88D2D287C890078CB2F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -137,6 +179,8 @@ children = ( 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */, 357FC57E54FD0F51795CF28A /* Pods_ShareExtension.framework */, + F0B57D392DF764BD00DC5BCC /* WidgetKit.framework */, + F0B57D3B2DF764BD00DC5BCC /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -167,6 +211,7 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, FAC6F8B62D287F120078CB2F /* ShareExtension */, + F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */, 97C146EF1CF9000F007C117D /* Products */, 0FB772A5B9601143383626CA /* Pods */, 1754452DD81DA6620E279E51 /* Frameworks */, @@ -178,6 +223,7 @@ children = ( 97C146EE1CF9000F007C117D /* Immich-Debug.app */, FAC6F8902D287C890078CB2F /* ShareExtension.appex */, + F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -234,6 +280,7 @@ ); dependencies = ( FAC6F8992D287C890078CB2F /* PBXTargetDependency */, + F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( B2CF7F8C2DDE4EBB00744BF6 /* Sync */, @@ -243,6 +290,26 @@ productReference = 97C146EE1CF9000F007C117D /* Immich-Debug.app */; 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 */ = { isa = PBXNativeTarget; buildConfigurationList = FAC6F8A02D287C890078CB2F /* Build configuration list for PBXNativeTarget "ShareExtension" */; @@ -268,7 +335,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1600; + LastSwiftUpdateCheck = 1640; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -277,6 +344,9 @@ LastSwiftMigration = 1100; ProvisioningStyle = Automatic; }; + F0B57D372DF764BD00DC5BCC = { + CreatedOnToolsVersion = 16.4; + }; FAC6F88F2D287C890078CB2F = { CreatedOnToolsVersion = 16.0; ProvisioningStyle = Automatic; @@ -298,6 +368,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, FAC6F88F2D287C890078CB2F /* ShareExtension */, + F0B57D372DF764BD00DC5BCC /* WidgetExtension */, ); }; /* End PBXProject section */ @@ -314,6 +385,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F0B57D362DF764BD00DC5BCC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FAC6F88E2D287C890078CB2F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -448,6 +527,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F0B57D342DF764BD00DC5BCC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; FAC6F88C2D287C890078CB2F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -459,6 +545,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F0B57D372DF764BD00DC5BCC /* WidgetExtension */; + targetProxy = F0B57D472DF764BE00DC5BCC /* PBXContainerItemProxy */; + }; FAC6F8992D287C890078CB2F /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FAC6F88F2D287C890078CB2F /* ShareExtension */; @@ -751,6 +842,129 @@ }; 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 */ = { isa = XCBuildConfiguration; baseConfigurationReference = F8A35EA3C3E01BD66AFDE0E5 /* Pods-ShareExtension.debug.xcconfig */; @@ -900,6 +1114,16 @@ defaultConfigurationIsVisible = 0; 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" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/mobile/ios/WidgetExtension/Assets.xcassets/Contents.json b/mobile/ios/WidgetExtension/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/mobile/ios/WidgetExtension/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/ios/WidgetExtension/EntryGenerators.swift b/mobile/ios/WidgetExtension/EntryGenerators.swift new file mode 100644 index 0000000000..6c1e1d4118 --- /dev/null +++ b/mobile/ios/WidgetExtension/EntryGenerators.swift @@ -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 +} diff --git a/mobile/ios/WidgetExtension/ImageWidgetView.swift b/mobile/ios/WidgetExtension/ImageWidgetView.swift new file mode 100644 index 0000000000..ff11133e51 --- /dev/null +++ b/mobile/ios/WidgetExtension/ImageWidgetView.swift @@ -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" + ) + } +) diff --git a/mobile/ios/WidgetExtension/ImmichAPI.swift b/mobile/ios/WidgetExtension/ImmichAPI.swift new file mode 100644 index 0000000000..4da610f1c7 --- /dev/null +++ b/mobile/ios/WidgetExtension/ImmichAPI.swift @@ -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 + } +} diff --git a/mobile/ios/WidgetExtension/Info.plist b/mobile/ios/WidgetExtension/Info.plist new file mode 100644 index 0000000000..0f118fb75e --- /dev/null +++ b/mobile/ios/WidgetExtension/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/mobile/ios/WidgetExtension/UIImage+Resize.swift b/mobile/ios/WidgetExtension/UIImage+Resize.swift new file mode 100644 index 0000000000..40bb9e2ace --- /dev/null +++ b/mobile/ios/WidgetExtension/UIImage+Resize.swift @@ -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)) + } + } +} diff --git a/mobile/ios/WidgetExtension/WidgetBundle.swift b/mobile/ios/WidgetExtension/WidgetBundle.swift new file mode 100644 index 0000000000..2f125608e4 --- /dev/null +++ b/mobile/ios/WidgetExtension/WidgetBundle.swift @@ -0,0 +1,10 @@ +import SwiftUI +import WidgetKit + +@main +struct ImmichWidgetBundle: WidgetBundle { + var body: some Widget { + ImmichRandomWidget() + ImmichMemoryWidget() + } +} diff --git a/mobile/ios/WidgetExtension/WidgetExtension.entitlements b/mobile/ios/WidgetExtension/WidgetExtension.entitlements new file mode 100644 index 0000000000..4ad1a257d8 --- /dev/null +++ b/mobile/ios/WidgetExtension/WidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.immich.share + + + diff --git a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift new file mode 100644 index 0000000000..516bf6905e --- /dev/null +++ b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift @@ -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) -> 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.") + } +} diff --git a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift new file mode 100644 index 0000000000..e3590b70ca --- /dev/null +++ b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift @@ -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 + { + 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.") + } +} diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 3d9d9a9063..6d98152efc 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -20,3 +20,15 @@ const String kSecuredPinCode = "secured_pin_code"; const int kTimelineNoneSegmentSize = 120; const int kTimelineAssetLoadBatchSize = 256; 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 kWidgetNames = [ + 'com.immich.widget.random', + 'com.immich.widget.memory', +]; diff --git a/mobile/lib/interfaces/widget.interface.dart b/mobile/lib/interfaces/widget.interface.dart new file mode 100644 index 0000000000..f76fbef8de --- /dev/null +++ b/mobile/lib/interfaces/widget.interface.dart @@ -0,0 +1,5 @@ +abstract interface class IWidgetRepository { + Future saveData(String key, String value); + Future refresh(String name); + Future setAppGroupId(String appGroupId); +} diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 5207858f99..dfbd18953a 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -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/auth.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:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -23,6 +24,7 @@ final authProvider = StateNotifierProvider((ref) { ref.watch(apiServiceProvider), ref.watch(userServiceProvider), ref.watch(secureStorageServiceProvider), + ref.watch(widgetServiceProvider), ); }); @@ -31,6 +33,7 @@ class AuthNotifier extends StateNotifier { final ApiService _apiService; final UserService _userService; final SecureStorageService _secureStorageService; + final WidgetService _widgetService; final _log = Logger("AuthenticationNotifier"); static const Duration _timeoutDuration = Duration(seconds: 7); @@ -40,6 +43,7 @@ class AuthNotifier extends StateNotifier { this._apiService, this._userService, this._secureStorageService, + this._widgetService, ) : super( AuthState( deviceId: "", @@ -76,6 +80,8 @@ class AuthNotifier extends StateNotifier { Future logout() async { try { await _secureStorageService.delete(kSecuredPinCode); + await _widgetService.clearCredentials(); + await _authService.logout(); } finally { await _cleanUp(); @@ -112,6 +118,11 @@ class AuthNotifier extends StateNotifier { }) async { 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 String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid; diff --git a/mobile/lib/repositories/widget.repository.dart b/mobile/lib/repositories/widget.repository.dart new file mode 100644 index 0000000000..a813bc56d6 --- /dev/null +++ b/mobile/lib/repositories/widget.repository.dart @@ -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 saveData(String key, String value) async { + await HomeWidget.saveWidgetData(key, value); + } + + @override + Future refresh(String name) async { + await HomeWidget.updateWidget(name: name, iOSName: name); + } + + @override + Future setAppGroupId(String appGroupId) async { + await HomeWidget.setAppGroupId(appGroupId); + } +} diff --git a/mobile/lib/services/widget.service.dart b/mobile/lib/services/widget.service.dart new file mode 100644 index 0000000000..bb7b367c27 --- /dev/null +++ b/mobile/lib/services/widget.service.dart @@ -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 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 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 refreshWidgets() async { + for (final name in kWidgetNames) { + await _repository.refresh(name); + } + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 79f2901b7d..3be12d497c 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -863,6 +863,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 9980622185..a70ae25bfa 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: fluttertoast: ^8.2.12 geolocator: ^14.0.0 hooks_riverpod: ^2.6.1 + home_widget: ^0.8.0 http: ^1.3.0 image_picker: ^1.1.2 intl: ^0.19.0