diff --git a/RubyEvents.xcodeproj/project.pbxproj b/RubyEvents.xcodeproj/project.pbxproj index 17c031b..a943fa1 100644 --- a/RubyEvents.xcodeproj/project.pbxproj +++ b/RubyEvents.xcodeproj/project.pbxproj @@ -10,10 +10,41 @@ 973DD3652D95F8BE008E0AED /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 973DD3642D95F8BE008E0AED /* CachedAsyncImage */; }; 97613B4C2D0635C600220031 /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 97613B4B2D0635C600220031 /* HotwireNative */; }; 97613B572D06512900220031 /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 97613B562D06512900220031 /* YouTubePlayerKit */; }; + 97AAF32F2FFF2733009DB8C2 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97AAF32E2FFF2733009DB8C2 /* WidgetKit.framework */; }; + 97AAF3312FFF2733009DB8C2 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 97AAF3302FFF2733009DB8C2 /* SwiftUI.framework */; }; + 97AAF3422FFF2734009DB8C2 /* RubyEventsWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 97AAF32C2FFF2733009DB8C2 /* RubyEventsWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 97AAF3402FFF2734009DB8C2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97613B0E2D0634F500220031 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97AAF32B2FFF2733009DB8C2; + remoteInfo = RubyEventsWidgetsExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 97AAF3432FFF2734009DB8C2 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 97AAF3422FFF2734009DB8C2 /* RubyEventsWidgetsExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 97613B162D0634F500220031 /* RubyEvents.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RubyEvents.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97AAF32C2FFF2733009DB8C2 /* RubyEventsWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RubyEventsWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 97AAF32E2FFF2733009DB8C2 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 97AAF3302FFF2733009DB8C2 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 97AAF3552FFF37FE009DB8C2 /* RubyEventsWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RubyEventsWidgetsExtension.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -24,6 +55,21 @@ ); target = 97613B152D0634F500220031 /* RubyEvents */; }; + 97AAF3472FFF2734009DB8C2 /* Exceptions for "RubyEventsWidgets" folder in "RubyEventsWidgetsExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 97AAF32B2FFF2733009DB8C2 /* RubyEventsWidgetsExtension */; + }; + 97AAF3502FFF2D25009DB8C2 /* Exceptions for "RubyEvents" folder in "RubyEventsWidgetsExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + liveactivity/LiveActivityShared.swift, + liveactivity/ScheduleActivityAttributes.swift, + ); + target = 97AAF32B2FFF2733009DB8C2 /* RubyEventsWidgetsExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -31,10 +77,19 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 97613B3E2D0634FA00220031 /* Exceptions for "RubyEvents" folder in "RubyEvents" target */, + 97AAF3502FFF2D25009DB8C2 /* Exceptions for "RubyEvents" folder in "RubyEventsWidgetsExtension" target */, ); path = RubyEvents; sourceTree = ""; }; + 97AAF3322FFF2733009DB8C2 /* RubyEventsWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 97AAF3472FFF2734009DB8C2 /* Exceptions for "RubyEventsWidgets" folder in "RubyEventsWidgetsExtension" target */, + ); + path = RubyEventsWidgets; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -48,13 +103,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 97AAF3292FFF2733009DB8C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 97AAF3312FFF2733009DB8C2 /* SwiftUI.framework in Frameworks */, + 97AAF32F2FFF2733009DB8C2 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 97613B0D2D0634F500220031 = { isa = PBXGroup; children = ( + 97AAF3552FFF37FE009DB8C2 /* RubyEventsWidgetsExtension.entitlements */, 97613B182D0634F500220031 /* RubyEvents */, + 97AAF3322FFF2733009DB8C2 /* RubyEventsWidgets */, + 97AAF32D2FFF2733009DB8C2 /* Frameworks */, 97613B172D0634F500220031 /* Products */, ); sourceTree = ""; @@ -63,10 +130,20 @@ isa = PBXGroup; children = ( 97613B162D0634F500220031 /* RubyEvents.app */, + 97AAF32C2FFF2733009DB8C2 /* RubyEventsWidgetsExtension.appex */, ); name = Products; sourceTree = ""; }; + 97AAF32D2FFF2733009DB8C2 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 97AAF32E2FFF2733009DB8C2 /* WidgetKit.framework */, + 97AAF3302FFF2733009DB8C2 /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -77,10 +154,12 @@ 97613B122D0634F500220031 /* Sources */, 97613B132D0634F500220031 /* Frameworks */, 97613B142D0634F500220031 /* Resources */, + 97AAF3432FFF2734009DB8C2 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 97AAF3412FFF2734009DB8C2 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 97613B182D0634F500220031 /* RubyEvents */, @@ -95,6 +174,28 @@ productReference = 97613B162D0634F500220031 /* RubyEvents.app */; productType = "com.apple.product-type.application"; }; + 97AAF32B2FFF2733009DB8C2 /* RubyEventsWidgetsExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97AAF3462FFF2734009DB8C2 /* Build configuration list for PBXNativeTarget "RubyEventsWidgetsExtension" */; + buildPhases = ( + 97AAF3282FFF2733009DB8C2 /* Sources */, + 97AAF3292FFF2733009DB8C2 /* Frameworks */, + 97AAF32A2FFF2733009DB8C2 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 97AAF3322FFF2733009DB8C2 /* RubyEventsWidgets */, + ); + name = RubyEventsWidgetsExtension; + packageProductDependencies = ( + ); + productName = RubyEventsWidgetsExtension; + productReference = 97AAF32C2FFF2733009DB8C2 /* RubyEventsWidgetsExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -102,12 +203,15 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1610; + LastSwiftUpdateCheck = 2620; LastUpgradeCheck = 1610; TargetAttributes = { 97613B152D0634F500220031 = { CreatedOnToolsVersion = 16.1; }; + 97AAF32B2FFF2733009DB8C2 = { + CreatedOnToolsVersion = 26.2; + }; }; }; buildConfigurationList = 97613B112D0634F500220031 /* Build configuration list for PBXProject "RubyEvents" */; @@ -130,6 +234,7 @@ projectRoot = ""; targets = ( 97613B152D0634F500220031 /* RubyEvents */, + 97AAF32B2FFF2733009DB8C2 /* RubyEventsWidgetsExtension */, ); }; /* End PBXProject section */ @@ -142,6 +247,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 97AAF32A2FFF2733009DB8C2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -152,8 +264,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 97AAF3282FFF2733009DB8C2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 97AAF3412FFF2734009DB8C2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97AAF32B2FFF2733009DB8C2 /* RubyEventsWidgetsExtension */; + targetProxy = 97AAF3402FFF2734009DB8C2 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 97613B402D0634FA00220031 /* Debug */ = { isa = XCBuildConfiguration; @@ -161,6 +288,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = RubyEvents/RubyEvents.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5UY2R75SM3; @@ -168,12 +296,14 @@ INFOPLIST_FILE = RubyEvents/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = RubyEvents; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + INFOPLIST_KEY_UIUserInterfaceStyle = Light; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -193,6 +323,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = RubyEvents/RubyEvents.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5UY2R75SM3; @@ -200,12 +331,14 @@ INFOPLIST_FILE = RubyEvents/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = RubyEvents; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + INFOPLIST_KEY_UIUserInterfaceStyle = Light; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -338,6 +471,70 @@ }; name = Release; }; + 97AAF3442FFF2734009DB8C2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = RubyEventsWidgetsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5UY2R75SM3; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RubyEventsWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = RubyEventsWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.rubyevents.RubyEvents.RubyEventsWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97AAF3452FFF2734009DB8C2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = RubyEventsWidgetsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5UY2R75SM3; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RubyEventsWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = RubyEventsWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.rubyevents.RubyEvents.RubyEventsWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -359,6 +556,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 97AAF3462FFF2734009DB8C2 /* Build configuration list for PBXNativeTarget "RubyEventsWidgetsExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97AAF3442FFF2734009DB8C2 /* Debug */, + 97AAF3452FFF2734009DB8C2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate b/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate index b82f64c..db59f34 100644 Binary files a/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate and b/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/RubyEvents.xcodeproj/xcuserdata/marcoroth.xcuserdatad/xcschemes/xcschememanagement.plist b/RubyEvents.xcodeproj/xcuserdata/marcoroth.xcuserdatad/xcschemes/xcschememanagement.plist index 5ea6c5c..fd3ea77 100644 --- a/RubyEvents.xcodeproj/xcuserdata/marcoroth.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/RubyEvents.xcodeproj/xcuserdata/marcoroth.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,11 +4,21 @@ SchemeUserState + AnyCodable (Playground).xcscheme + + orderHint + 3 + RubyEvents.xcscheme_^#shared#^_ orderHint 0 + RubyEventsWidgetsExtension.xcscheme_^#shared#^_ + + orderHint + 3 + diff --git a/RubyEvents/App.swift b/RubyEvents/App.swift index 0731079..28313d9 100644 --- a/RubyEvents/App.swift +++ b/RubyEvents/App.swift @@ -78,6 +78,29 @@ class App { func navigatorFor(title: String) -> Navigator? { tabBarController.navigatorFor(title: title) } + + func route(_ url: URL) { + let target = webURL(from: url) + + guard isTabbed else { + navigator.route(target) + return + } + + if let index = tabBarController.configuration?.items.firstIndex(where: { $0.title == "Events" }) { + tabBarController.selectedIndex = index + } + + (navigatorFor(title: "Events") ?? tabBarController.currentNavigator)?.route(target) + } + + private func webURL(from url: URL) -> URL { + guard url.scheme == "rubyevents" else { return url } + + let path = "/\(url.host ?? "")\(url.path)" + + return Router.instance.root_url().appendingPathComponent(path) + } } extension App: NavigatorDelegate { diff --git a/RubyEvents/AppDelegate.swift b/RubyEvents/AppDelegate.swift index da3323d..668e704 100644 --- a/RubyEvents/AppDelegate.swift +++ b/RubyEvents/AppDelegate.swift @@ -19,7 +19,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Hotwire.config.applicationUserAgentPrefix = "Hotwire Native iOS; app_version: \(versionNumber); unique_device_id: \(uniqueDeviceId);" Hotwire.registerBridgeComponents([ - ButtonComponent.self + ButtonComponent.self, + LiveActivityComponent.self ]) Hotwire.config.showDoneButtonOnModals = true diff --git a/RubyEvents/Info.plist b/RubyEvents/Info.plist index e0f24c0..e9d4abf 100644 --- a/RubyEvents/Info.plist +++ b/RubyEvents/Info.plist @@ -2,8 +2,21 @@ - UIUserInterfaceStyle - Light + UIUserInterfaceStyle + Light + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + org.rubyevents.RubyEvents + CFBundleURLSchemes + + rubyevents + + + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -23,5 +36,9 @@ + UIBackgroundModes + + audio + diff --git a/RubyEvents/RubyEvents.entitlements b/RubyEvents/RubyEvents.entitlements new file mode 100644 index 0000000..0d57a92 --- /dev/null +++ b/RubyEvents/RubyEvents.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.rubyevents.RubyEvents + + + diff --git a/RubyEvents/SceneDelegate.swift b/RubyEvents/SceneDelegate.swift index f2d24ff..15a6138 100644 --- a/RubyEvents/SceneDelegate.swift +++ b/RubyEvents/SceneDelegate.swift @@ -5,5 +5,27 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { App.instance.start(sceneDelegate: self) + + if let url = connectionOptions.urlContexts.first?.url ?? connectionOptions.userActivities.first?.webpageURL { + routeAfterLaunch(url) + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + if let url = URLContexts.first?.url { + App.instance.route(url) + } + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + if let url = userActivity.webpageURL { + App.instance.route(url) + } + } + + private func routeAfterLaunch(_ url: URL) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + App.instance.route(url) + } } } diff --git a/RubyEvents/bridge/LiveActivityComponent.swift b/RubyEvents/bridge/LiveActivityComponent.swift new file mode 100644 index 0000000..754c534 --- /dev/null +++ b/RubyEvents/bridge/LiveActivityComponent.swift @@ -0,0 +1,57 @@ +// +// LiveActivityComponent.swift +// RubyEvents +// + +import Foundation +import HotwireNative +import UIKit + +final class LiveActivityComponent: BridgeComponent { + override class var name: String { "live-activity" } + + override func onReceive(message: Message) { + switch message.event { + case "status": + replyStatus(to: message) + + case "start": + guard let data: StartData = message.data() else { return } + + Task { + await LiveActivityManager.shared.start(eventSlug: data.slug, eventName: data.name ?? "") + + if LiveActivityManager.shared.isRunning { + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + + replyStatus(to: message) + } + + case "stop": + Task { + await LiveActivityManager.shared.stop() + replyStatus(to: message) + } + + default: + break + } + } + + private func replyStatus(to message: Message) { + let data: StartData? = message.data() + let active = data.map { LiveActivityManager.shared.isActive(eventSlug: $0.slug) } ?? false + + reply(to: message.event, with: StatusReply(active: active)) + } + + private struct StartData: Decodable { + let slug: String + let name: String? + } + + private struct StatusReply: Encodable { + let active: Bool + } +} diff --git a/RubyEvents/liveactivity/LiveActivityManager.swift b/RubyEvents/liveactivity/LiveActivityManager.swift new file mode 100644 index 0000000..ee138fa --- /dev/null +++ b/RubyEvents/liveactivity/LiveActivityManager.swift @@ -0,0 +1,335 @@ +// +// LiveActivityManager.swift +// RubyEvents +// + +import ActivityKit +import Foundation +import UIKit + +struct LiveScheduleResponse: Decodable { + struct EventInfo: Decodable { + let name: String + let slug: String + let avatar_url: String? + let featured_background: String? + let featured_color: String? + } + + let event: EventInfo + let time_zone: String? + let sessions: [LiveSession] +} + +struct LiveTalk: Decodable { + let title: String? + let speakers: [String]? + let speaker_avatars: [String]? + let track: String? + let track_color: String? +} + +struct LiveSession: Decodable { + let talks: [LiveTalk]? + let start_at: String? + let end_at: String? + let date: String? + + var startDate: Date? { start_at.flatMap { ISO8601DateFormatter().date(from: $0) } } + var endDate: Date? { end_at.flatMap { ISO8601DateFormatter().date(from: $0) } } +} + +@MainActor +final class LiveActivityManager { + static let shared = LiveActivityManager() + static let didEndNotification = Notification.Name("LiveActivityManager.didEnd") + + private var activity: Activity? + private var sessions: [LiveSession] = [] + private var eventSlug: String? + private var advanceTimer: Timer? + + private init() { + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { await self?.refreshIfRunning() } + } + } + + private var systemActivities: [Activity] { + Activity.activities + } + + var isRunning: Bool { !systemActivities.isEmpty } + var activeEventSlug: String? { systemActivities.first?.attributes.eventSlug } + + func isActive(eventSlug: String) -> Bool { + systemActivities.contains { $0.attributes.eventSlug == eventSlug } + } + + func start(eventSlug: String, eventName: String) async { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + guard let response = await fetchSchedule(eventSlug: eventSlug) else { return } + + sessions = sortedSessions(response.sessions) + + self.eventSlug = eventSlug + + if let existing = systemActivities.first(where: { $0.attributes.eventSlug == eventSlug }) { + activity = existing + observeActivityState() + + await advance() + + return + } + + await endActivities(exceptSlug: eventSlug) + + guard let state = await buildState() else { return } + + let attributes = ScheduleActivityAttributes( + eventName: response.event.name, + eventSlug: eventSlug, + featuredBackground: response.event.featured_background ?? "#000000", + featuredColor: response.event.featured_color ?? "#FFFFFF", + eventAvatarFile: await cacheImage(from: response.event.avatar_url, as: "event-avatar"), + scheduleURL: scheduleDeepLink(eventSlug: eventSlug) + ) + + do { + activity = try Activity.request( + attributes: attributes, + content: .init(state: state, staleDate: nextBoundary().addingTimeInterval(60)), + pushType: nil + ) + + scheduleAdvance() + observeActivityState() + } catch { + print("Live Activity start failed:", error) + } + } + + func stop() async { + advanceTimer?.invalidate() + advanceTimer = nil + + for activity in systemActivities { + await activity.end(nil, dismissalPolicy: .immediate) + } + + activity = nil + eventSlug = nil + sessions = [] + + NotificationCenter.default.post(name: Self.didEndNotification, object: nil) + } + + private func endActivities(exceptSlug: String?) async { + for activity in systemActivities where activity.attributes.eventSlug != exceptSlug { + await activity.end(nil, dismissalPolicy: .immediate) + } + } + + private func observeActivityState() { + guard let activity else { return } + + Task { [weak self] in + for await state in activity.activityStateUpdates { + if state == .ended || state == .dismissed { + self?.clearActiveState() + NotificationCenter.default.post(name: Self.didEndNotification, object: nil) + + return + } + } + } + } + + private func clearActiveState() { + advanceTimer?.invalidate() + advanceTimer = nil + activity = nil + eventSlug = nil + sessions = [] + } + + private func refreshIfRunning() async { + guard let eventSlug = activeEventSlug else { return } + + if activity == nil { + activity = systemActivities.first { $0.attributes.eventSlug == eventSlug } + self.eventSlug = eventSlug + observeActivityState() + } + + if let response = await fetchSchedule(eventSlug: eventSlug) { + sessions = sortedSessions(response.sessions) + } + + await advance() + } + + private func currentSession(_ now: Date = Date()) -> LiveSession? { + sessions.first { session in + guard let start = session.startDate, let end = session.endDate else { return false } + return start <= now && now < end + } + } + + private func nextSession(_ now: Date = Date()) -> LiveSession? { + sessions + .filter { ($0.startDate ?? .distantPast) > now } + .min { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) } + } + + private func isTalkSession(_ session: LiveSession) -> Bool { + (session.talks ?? []).contains { !($0.speakers ?? []).isEmpty } + } + + private func nextTalkSession(_ now: Date = Date()) -> LiveSession? { + sessions + .filter { ($0.startDate ?? .distantPast) > now && isTalkSession($0) } + .min { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) } + } + + private func buildState(_ now: Date = Date()) async -> ScheduleActivityAttributes.ContentState? { + let next = nextTalkSession(now) + let nextTalk = next?.talks?.first { !($0.speakers ?? []).isEmpty } ?? next?.talks?.first + + if let current = currentSession(now), let start = current.startDate, let end = current.endDate { + return .init( + talks: await sessionTalks(current), + sessionStart: start, + sessionEnd: end, + isLive: true, + nextTitle: nextTalk?.title, + nextSpeakers: nextTalk?.speakers?.joined(separator: ", "), + nextTrack: nextTalk?.track, + nextTrackColor: nextTalk?.track_color, + nextSpeakerAvatarFile: await cacheImage(from: nextTalk?.speaker_avatars?.first, as: "next-speaker"), + nextStart: next?.startDate + ) + } + + if let next, let start = next.startDate, let end = next.endDate { + return .init( + talks: await sessionTalks(next), + sessionStart: start, + sessionEnd: end, + isLive: false, + nextTitle: nil, + nextSpeakers: nil, + nextTrack: nil, + nextTrackColor: nil, + nextSpeakerAvatarFile: nil, + nextStart: nil + ) + } + + return nil + } + + private func sessionTalks(_ session: LiveSession) async -> [ScheduleActivityAttributes.SessionTalk] { + var result: [ScheduleActivityAttributes.SessionTalk] = [] + + for (index, talk) in (session.talks ?? []).enumerated() { + result.append(.init( + title: talk.title ?? "Session", + speakers: talk.speakers?.joined(separator: ", ") ?? "", + track: talk.track, + trackColor: talk.track_color, + avatarFile: await cacheImage(from: talk.speaker_avatars?.first, as: "talk-\(index)") + )) + } + + return result + } + + private func scheduleDeepLink(eventSlug: String) -> String { + let day = (currentSession() ?? nextSession() ?? sessions.first)?.date + + if let day { + return "rubyevents://events/\(eventSlug)/schedule/day/\(day)" + } + + return "rubyevents://events/\(eventSlug)/schedule" + } + + private func nextBoundary(_ now: Date = Date()) -> Date { + if let current = currentSession(now), let end = current.endDate { return end } + if let next = nextSession(now), let start = next.startDate { return start } + + return now.addingTimeInterval(3600) + } + + private func scheduleAdvance() { + advanceTimer?.invalidate() + + let interval = max(nextBoundary().timeIntervalSinceNow + 1, 1) + + advanceTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + Task { await self?.advance() } + } + } + + private func advance() async { + guard let activity else { return } + + guard let state = await buildState() else { + await stop() + return + } + + await activity.update(.init(state: state, staleDate: nextBoundary().addingTimeInterval(60))) + + scheduleAdvance() + } + + private func sortedSessions(_ sessions: [LiveSession]) -> [LiveSession] { + sessions.sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) } + } + + private func fetchSchedule(eventSlug: String) async -> LiveScheduleResponse? { + let endpoint = Router.instance.root_url() + .appendingPathComponent("/hotwire/native/v1/events/\(eventSlug)/live_schedule.json") + .absoluteString + + return try? await APIService.shared.fetchData(from: endpoint) + } + + private func cacheImage(from urlString: String?, as name: String) async -> String? { + guard let urlString, !urlString.isEmpty, + let destination = LiveActivityShared.imageURL(for: "\(name).png") else { return nil } + + let absolute = urlString.hasPrefix("http") + ? urlString + : Router.instance.root_url().appendingPathComponent(urlString).absoluteString + + guard let url = URL(string: absolute) else { return nil } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + guard let png = Self.downscaledPNG(data, maxDimension: 120) else { return nil } + + try png.write(to: destination, options: .atomic) + + return "\(name).png" + } catch { + return nil + } + } + + private static func downscaledPNG(_ data: Data, maxDimension: CGFloat) -> Data? { + guard let image = UIImage(data: data) else { return nil } + + let longest = max(image.size.width, image.size.height, 1) + let scale = min(1, maxDimension / longest) + let size = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let renderer = UIGraphicsImageRenderer(size: size) + + return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }.pngData() + } +} diff --git a/RubyEvents/liveactivity/LiveActivityShared.swift b/RubyEvents/liveactivity/LiveActivityShared.swift new file mode 100644 index 0000000..2e25cc7 --- /dev/null +++ b/RubyEvents/liveactivity/LiveActivityShared.swift @@ -0,0 +1,18 @@ +// +// LiveActivityShared.swift +// RubyEvents +// + +import Foundation + +enum LiveActivityShared { + static let appGroupID = "group.org.rubyevents.RubyEvents" + + static var containerURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) + } + + static func imageURL(for filename: String) -> URL? { + containerURL?.appendingPathComponent(filename) + } +} diff --git a/RubyEvents/liveactivity/ScheduleActivityAttributes.swift b/RubyEvents/liveactivity/ScheduleActivityAttributes.swift new file mode 100644 index 0000000..1941f72 --- /dev/null +++ b/RubyEvents/liveactivity/ScheduleActivityAttributes.swift @@ -0,0 +1,37 @@ +// +// ScheduleActivityAttributes.swift +// RubyEvents +// + +import ActivityKit +import Foundation + +struct ScheduleActivityAttributes: ActivityAttributes { + public struct SessionTalk: Codable, Hashable { + var title: String + var speakers: String + var track: String? + var trackColor: String? // hex + var avatarFile: String? + } + + public struct ContentState: Codable, Hashable { + var talks: [SessionTalk] + var sessionStart: Date + var sessionEnd: Date + var isLive: Bool + var nextTitle: String? + var nextSpeakers: String? + var nextTrack: String? + var nextTrackColor: String? + var nextSpeakerAvatarFile: String? + var nextStart: Date? + } + + var eventName: String + var eventSlug: String + var featuredBackground: String // hex + var featuredColor: String // hex + var eventAvatarFile: String? + var scheduleURL: String +} diff --git a/RubyEventsWidgets/AppIntent.swift b/RubyEventsWidgets/AppIntent.swift new file mode 100644 index 0000000..5e6752b --- /dev/null +++ b/RubyEventsWidgets/AppIntent.swift @@ -0,0 +1,15 @@ +// +// AppIntent.swift +// RubyEventsWidgets +// + +import WidgetKit +import AppIntents + +struct ConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Configuration" } + static var description: IntentDescription { "This is an example widget." } + + @Parameter(title: "Favorite Emoji", default: "πŸ˜ƒ") + var favoriteEmoji: String +} diff --git a/RubyEventsWidgets/Assets.xcassets/AccentColor.colorset/Contents.json b/RubyEventsWidgets/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/RubyEventsWidgets/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RubyEventsWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json b/RubyEventsWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/RubyEventsWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RubyEventsWidgets/Assets.xcassets/Contents.json b/RubyEventsWidgets/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/RubyEventsWidgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RubyEventsWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json b/RubyEventsWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/RubyEventsWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RubyEventsWidgets/Info.plist b/RubyEventsWidgets/Info.plist new file mode 100644 index 0000000..464a4f8 --- /dev/null +++ b/RubyEventsWidgets/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + NSSupportsLiveActivities + + + diff --git a/RubyEventsWidgets/RubyEventsWidgets.swift b/RubyEventsWidgets/RubyEventsWidgets.swift new file mode 100644 index 0000000..56e4a82 --- /dev/null +++ b/RubyEventsWidgets/RubyEventsWidgets.swift @@ -0,0 +1,88 @@ +// +// RubyEventsWidgets.swift +// RubyEventsWidgets +// +// Created by Marco Roth on 09.07.2026. +// + +import WidgetKit +import SwiftUI + +struct Provider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> SimpleEntry { + SimpleEntry(date: Date(), configuration: ConfigurationAppIntent()) + } + + func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { + SimpleEntry(date: Date(), configuration: configuration) + } + + func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { + var entries: [SimpleEntry] = [] + + // Generate a timeline consisting of five entries an hour apart, starting from the current date. + let currentDate = Date() + for hourOffset in 0 ..< 5 { + let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! + let entry = SimpleEntry(date: entryDate, configuration: configuration) + entries.append(entry) + } + + return Timeline(entries: entries, policy: .atEnd) + } + +// func relevances() async -> WidgetRelevances { +// // Generate a list containing the contexts this widget is relevant in. +// } +} + +struct SimpleEntry: TimelineEntry { + let date: Date + let configuration: ConfigurationAppIntent +} + +struct RubyEventsWidgetsEntryView: View { + var entry: Provider.Entry + + var body: some View { + VStack { + Text("Time:") + Text(entry.date, style: .time) + + Text("Favorite Emoji:") + Text(entry.configuration.favoriteEmoji) + } + } +} + +struct RubyEventsWidgets: Widget { + let kind: String = "RubyEventsWidgets" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in + RubyEventsWidgetsEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + } +} + +extension ConfigurationAppIntent { + fileprivate static var smiley: ConfigurationAppIntent { + let intent = ConfigurationAppIntent() + intent.favoriteEmoji = "πŸ˜€" + return intent + } + + fileprivate static var starEyes: ConfigurationAppIntent { + let intent = ConfigurationAppIntent() + intent.favoriteEmoji = "🀩" + return intent + } +} + +#Preview(as: .systemSmall) { + RubyEventsWidgets() +} timeline: { + SimpleEntry(date: .now, configuration: .smiley) + SimpleEntry(date: .now, configuration: .starEyes) +} diff --git a/RubyEventsWidgets/RubyEventsWidgetsBundle.swift b/RubyEventsWidgets/RubyEventsWidgetsBundle.swift new file mode 100644 index 0000000..1aa540e --- /dev/null +++ b/RubyEventsWidgets/RubyEventsWidgetsBundle.swift @@ -0,0 +1,18 @@ +// +// RubyEventsWidgetsBundle.swift +// RubyEventsWidgets +// +// Created by Marco Roth on 09.07.2026. +// + +import WidgetKit +import SwiftUI + +@main +struct RubyEventsWidgetsBundle: WidgetBundle { + var body: some Widget { + RubyEventsWidgets() + RubyEventsWidgetsControl() + RubyEventsWidgetsLiveActivity() + } +} diff --git a/RubyEventsWidgets/RubyEventsWidgetsControl.swift b/RubyEventsWidgets/RubyEventsWidgetsControl.swift new file mode 100644 index 0000000..f0af355 --- /dev/null +++ b/RubyEventsWidgets/RubyEventsWidgetsControl.swift @@ -0,0 +1,77 @@ +// +// RubyEventsWidgetsControl.swift +// RubyEventsWidgets +// +// Created by Marco Roth on 09.07.2026. +// + +import AppIntents +import SwiftUI +import WidgetKit + +struct RubyEventsWidgetsControl: ControlWidget { + static let kind: String = "org.rubyevents.RubyEvents.RubyEventsWidgets" + + var body: some ControlWidgetConfiguration { + AppIntentControlConfiguration( + kind: Self.kind, + provider: Provider() + ) { value in + ControlWidgetToggle( + "Start Timer", + isOn: value.isRunning, + action: StartTimerIntent(value.name) + ) { isRunning in + Label(isRunning ? "On" : "Off", systemImage: "timer") + } + } + .displayName("Timer") + .description("A an example control that runs a timer.") + } +} + +extension RubyEventsWidgetsControl { + struct Value { + var isRunning: Bool + var name: String + } + + struct Provider: AppIntentControlValueProvider { + func previewValue(configuration: TimerConfiguration) -> Value { + RubyEventsWidgetsControl.Value(isRunning: false, name: configuration.timerName) + } + + func currentValue(configuration: TimerConfiguration) async throws -> Value { + let isRunning = true // Check if the timer is running + return RubyEventsWidgetsControl.Value(isRunning: isRunning, name: configuration.timerName) + } + } +} + +struct TimerConfiguration: ControlConfigurationIntent { + static let title: LocalizedStringResource = "Timer Name Configuration" + + @Parameter(title: "Timer Name", default: "Timer") + var timerName: String +} + +struct StartTimerIntent: SetValueIntent { + static let title: LocalizedStringResource = "Start a timer" + + @Parameter(title: "Timer Name") + var name: String + + @Parameter(title: "Timer is running") + var value: Bool + + init() {} + + init(_ name: String) { + self.name = name + } + + func perform() async throws -> some IntentResult { + // Start the timer… + return .result() + } +} diff --git a/RubyEventsWidgets/RubyEventsWidgetsLiveActivity.swift b/RubyEventsWidgets/RubyEventsWidgetsLiveActivity.swift new file mode 100644 index 0000000..ce6f1c3 --- /dev/null +++ b/RubyEventsWidgets/RubyEventsWidgetsLiveActivity.swift @@ -0,0 +1,344 @@ +// +// RubyEventsWidgetsLiveActivity.swift +// RubyEventsWidgets +// + +import ActivityKit +import SwiftUI +import UIKit +import WidgetKit + +private typealias Talk = ScheduleActivityAttributes.SessionTalk + +private func sharedImage(_ filename: String?) -> Image? { + guard let filename, + let url = LiveActivityShared.imageURL(for: filename), + let uiImage = UIImage(contentsOfFile: url.path) else { return nil } + + return Image(uiImage: uiImage) +} + +private func hexColor(_ hex: String?) -> Color { + guard let hex else { return .gray } + + let string = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex + + guard string.count == 6, let value = UInt64(string, radix: 16) else { return .gray } + + return Color( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255 + ) +} + +private func darkerColor(_ hex: String, by factor: Double = 0.2) -> Color { + let string = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex + guard string.count == 6, let value = UInt64(string, radix: 16) else { return .black } + let scale = 1 - factor + + return Color( + red: Double((value >> 16) & 0xFF) / 255 * scale, + green: Double((value >> 8) & 0xFF) / 255 * scale, + blue: Double(value & 0xFF) / 255 * scale + ) +} + +private func timeRange(_ start: Date, _ end: Date) -> String { + "\(start.formatted(date: .omitted, time: .shortened)) – \(end.formatted(date: .omitted, time: .shortened))" +} + +@ViewBuilder +private func trackBadge(_ track: String?, _ colorHex: String?) -> some View { + if let track, !track.isEmpty { + Text(track) + .font(.caption2.weight(.bold)) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(hexColor(colorHex)) + .foregroundStyle(.white) + .clipShape(Capsule()) + } +} + +@ViewBuilder +private func avatarCircle(_ filename: String?, size: CGFloat, border: Color) -> some View { + if let avatar = sharedImage(filename) { + avatar.resizable().scaledToFill() + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(Circle().strokeBorder(border, lineWidth: 0.5)) + } +} + +struct RubyEventsWidgetsLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: ScheduleActivityAttributes.self) { context in + LiveActivityLockScreen(context: context) + .activityBackgroundTint(hexColor(context.attributes.featuredBackground)) + .activitySystemActionForegroundColor(hexColor(context.attributes.featuredColor)) + } dynamicIsland: { context in + let accent = hexColor(context.attributes.featuredColor) + let talks = context.state.talks + + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + if let avatar = sharedImage(context.attributes.eventAvatarFile) { + avatar.resizable().scaledToFill() + .frame(width: 38, height: 38) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + Label("LIVE", systemImage: "record.circle.fill") + .font(.caption2.weight(.semibold)).foregroundStyle(.red) + } + } + DynamicIslandExpandedRegion(.trailing) { + if context.state.isLive { + Text(timerInterval: context.state.sessionStart...context.state.sessionEnd, countsDown: true) + .font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width: 56) + } + } + DynamicIslandExpandedRegion(.center) { + VStack(alignment: .leading, spacing: 2) { + Text(talks.first?.title ?? "Session") + .font(.subheadline.weight(.semibold)).lineLimit(2) + Text(timeRange(context.state.sessionStart, context.state.sessionEnd)) + .font(.caption2).foregroundStyle(.secondary) + } + } + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 4) { + ForEach(0.. 1 ? talks[i].title : talks[i].speakers) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + if talks.count > 2 { + Text("+\(talks.count - 2) more").font(.caption2).foregroundStyle(.secondary) + } + if let next = context.state.nextTitle { + Text("Up next: \(next)").font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + } + } + } compactLeading: { + Image(systemName: "record.circle.fill").foregroundStyle(.red) + } compactTrailing: { + if context.state.isLive { + Text(timerInterval: context.state.sessionStart...context.state.sessionEnd, countsDown: true) + .font(.caption2.monospacedDigit()).frame(width: 40) + } + } minimal: { + Image(systemName: "record.circle.fill").foregroundStyle(.red) + } + .keylineTint(accent) + } + } +} + +struct LiveActivityLockScreen: View { + let context: ActivityViewContext + + private var background: Color { hexColor(context.attributes.featuredBackground) } + private var foreground: Color { hexColor(context.attributes.featuredColor) } + + private var visibleTalks: [Talk] { Array(context.state.talks.prefix(2)) } + private var extraTalks: Int { max(0, context.state.talks.count - 2) } + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: context.state.talks.count > 1 ? .center : .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + eventAvatar + + VStack(alignment: .leading, spacing: 1) { + Text("\(context.state.sessionStart.formatted(date: .omitted, time: .shortened)) –") + .font(.caption2) + .foregroundStyle(foreground.opacity(0.8)) + .lineLimit(1) + Text(context.state.sessionEnd.formatted(date: .omitted, time: .shortened)) + .font(.caption2) + .foregroundStyle(foreground.opacity(0.55)) + .lineLimit(1) + + if context.state.isLive { + Text(timerInterval: context.state.sessionStart...context.state.sessionEnd, countsDown: true) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(foreground) + .lineLimit(1) + .padding(.top, 8) + } + } + } + .frame(width: 62, alignment: .leading) + + VStack(alignment: .leading, spacing: 10) { + ForEach(visibleTalks.indices, id: \.self) { i in + if i > 0 { + Rectangle() + .fill(foreground.opacity(0.18)) + .frame(height: 0.5) + } + talkBlock(visibleTalks[i]) + } + if extraTalks > 0 { + Text("+\(extraTalks) more") + .font(.caption2.weight(.medium)) + .foregroundStyle(foreground.opacity(0.6)) + } + } + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(background) + + if let next = context.state.nextTitle { + Group { + if context.state.talks.count <= 1 { + upNextFull(next) + } else { + upNextCompact(next) + } + } + .padding(.horizontal, 16) + .padding(.vertical, context.state.talks.count <= 1 ? 12 : 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(darkerColor(context.attributes.featuredBackground)) + } + } + .widgetURL(URL(string: context.attributes.scheduleURL)) + } + + private func talkBlock(_ talk: Talk) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(talk.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(foreground) + .lineLimit(context.state.talks.count > 1 ? 1 : 2) + + if !talk.speakers.isEmpty || !(talk.track ?? "").isEmpty { + HStack(spacing: 6) { + if !talk.speakers.isEmpty { + avatarCircle(talk.avatarFile, size: 18, border: foreground.opacity(0.25)) + + Text(talk.speakers).font(.caption).foregroundStyle(foreground.opacity(0.85)).lineLimit(1) + } + + Spacer(minLength: 4) + trackBadge(talk.track, talk.trackColor) + } + } + } + } + + private func upNextCompact(_ next: String) -> some View { + HStack(alignment: .center, spacing: 8) { + Text("UP NEXT") + .font(.caption2.weight(.bold)) + .foregroundStyle(foreground.opacity(0.55)) + + avatarCircle(context.state.nextSpeakerAvatarFile, size: 16, border: foreground.opacity(0.25)) + + Text(next).font(.caption2).foregroundStyle(foreground.opacity(0.85)).lineLimit(1) + + Spacer(minLength: 4) + + trackBadge(context.state.nextTrack, context.state.nextTrackColor) + + if let start = context.state.nextStart { + Text(start.formatted(date: .omitted, time: .shortened)) + .font(.caption2.monospacedDigit()).foregroundStyle(foreground.opacity(0.85)) + } + } + .frame(height: 20) + } + + private func upNextFull(_ next: String) -> some View { + HStack(alignment: .center, spacing: 12) { + if sharedImage(context.state.nextSpeakerAvatarFile) != nil { + avatarCircle(context.state.nextSpeakerAvatarFile, size: 40, border: foreground.opacity(0.25)) + } + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text("UP NEXT") + .font(.caption2.weight(.bold)) + .foregroundStyle(foreground.opacity(0.55)) + trackBadge(context.state.nextTrack, context.state.nextTrackColor) + + Spacer(minLength: 4) + + if let start = context.state.nextStart { + Text(start.formatted(date: .omitted, time: .shortened)) + .font(.caption.monospacedDigit()).foregroundStyle(foreground.opacity(0.85)) + } + } + + Text(next) + .font(.subheadline.weight(.semibold)).foregroundStyle(foreground).lineLimit(1) + + if let speakers = context.state.nextSpeakers, !speakers.isEmpty { + Text(speakers) + .font(.caption).foregroundStyle(foreground.opacity(0.8)).lineLimit(1) + } + } + } + } + + @ViewBuilder + private var eventAvatar: some View { + if let avatar = sharedImage(context.attributes.eventAvatarFile) { + avatar.resizable().scaledToFill() + .frame(width: 46, height: 46) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(foreground.opacity(0.25), lineWidth: 1)) + } else { + Image(systemName: "record.circle.fill") + .font(.title2).foregroundStyle(.red).frame(width: 46, height: 46) + } + } +} + +extension ScheduleActivityAttributes { + fileprivate static var preview: ScheduleActivityAttributes { + ScheduleActivityAttributes( + eventName: "Rails World 2025", + eventSlug: "rails-world-2025", + featuredBackground: "#3B1D62", + featuredColor: "#FFFFFF", + eventAvatarFile: nil, + scheduleURL: "rubyevents://events/rails-world-2025/schedule/day/2025-09-04" + ) + } +} + +extension ScheduleActivityAttributes.ContentState { + fileprivate static var sample: ScheduleActivityAttributes.ContentState { + .init( + talks: [ + .init(title: "Multi-Tenant Rails", speakers: "Mike Dalessio", track: "Track 1", trackColor: "#DC2626", avatarFile: nil), + .init(title: "Startup Speed, Enterprise Scale", speakers: "Austin Story", track: "Track 2", trackColor: "#2563EB", avatarFile: nil) + ], + sessionStart: .now, + sessionEnd: .now.addingTimeInterval(45 * 60), + isLive: true, + nextTitle: "SQLite Replication with Beamer", + nextSpeakers: "Kevin McConnell", + nextTrack: "Track 1", + nextTrackColor: "#DC2626", + nextSpeakerAvatarFile: nil, + nextStart: .now.addingTimeInterval(45 * 60) + ) + } +} + +#Preview("Live Activity", as: .content, using: ScheduleActivityAttributes.preview) { + RubyEventsWidgetsLiveActivity() +} contentStates: { + ScheduleActivityAttributes.ContentState.sample +} diff --git a/RubyEventsWidgetsExtension.entitlements b/RubyEventsWidgetsExtension.entitlements new file mode 100644 index 0000000..0d57a92 --- /dev/null +++ b/RubyEventsWidgetsExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.rubyevents.RubyEvents + + +