diff --git a/RubyEvents.xcodeproj/project.pbxproj b/RubyEvents.xcodeproj/project.pbxproj index 17c031b..4097ac7 100644 --- a/RubyEvents.xcodeproj/project.pbxproj +++ b/RubyEvents.xcodeproj/project.pbxproj @@ -10,13 +10,51 @@ 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 */ + 975673453001ECF200F713BE /* Exceptions for "RubyEventsWidgets" folder in "RubyEvents" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + NextEventWidget.swift, + ); + target = 97613B152D0634F500220031 /* RubyEvents */; + }; 97613B3E2D0634FA00220031 /* Exceptions for "RubyEvents" folder in "RubyEvents" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -24,6 +62,22 @@ ); 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/NextEventStore.swift, + liveactivity/ScheduleActivityAttributes.swift, + ); + target = 97AAF32B2FFF2733009DB8C2 /* RubyEventsWidgetsExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -31,10 +85,20 @@ 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 = ( + 975673453001ECF200F713BE /* Exceptions for "RubyEventsWidgets" folder in "RubyEvents" target */, + 97AAF3472FFF2734009DB8C2 /* Exceptions for "RubyEventsWidgets" folder in "RubyEventsWidgetsExtension" target */, + ); + path = RubyEventsWidgets; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -48,13 +112,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 +139,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 +163,12 @@ 97613B122D0634F500220031 /* Sources */, 97613B132D0634F500220031 /* Frameworks */, 97613B142D0634F500220031 /* Resources */, + 97AAF3432FFF2734009DB8C2 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 97AAF3412FFF2734009DB8C2 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 97613B182D0634F500220031 /* RubyEvents */, @@ -95,6 +183,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 +212,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 +243,7 @@ projectRoot = ""; targets = ( 97613B152D0634F500220031 /* RubyEvents */, + 97AAF32B2FFF2733009DB8C2 /* RubyEventsWidgetsExtension */, ); }; /* End PBXProject section */ @@ -142,6 +256,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 97AAF32A2FFF2733009DB8C2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -152,8 +273,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 +297,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 +305,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 = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -193,6 +332,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 +340,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 = 17.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -338,6 +480,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 = 18.0; + 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 = 18.0; + 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 +565,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..05e5b0b 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..3d5adc1 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 + 2 + diff --git a/RubyEvents/App.swift b/RubyEvents/App.swift index 0731079..2e7e782 100644 --- a/RubyEvents/App.swift +++ b/RubyEvents/App.swift @@ -63,6 +63,15 @@ class App { self.tabBarController.setupTabs() switchToTabController() + + NextEventUpdater.refresh() + NextEventUpdater.scheduleBackgroundRefresh() + + NotificationCenter.default.addObserver( + forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { _ in + NextEventUpdater.refresh() + } } func switchToNavigationController() { @@ -78,6 +87,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..77edfe9 100644 --- a/RubyEvents/AppDelegate.swift +++ b/RubyEvents/AppDelegate.swift @@ -19,12 +19,15 @@ 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 Hotwire.config.debugLoggingEnabled = true + NextEventUpdater.registerBackgroundTask() + Hotwire.loadPathConfiguration(from: [ .server(Router.instance.path_configuration_url()), .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!) diff --git a/RubyEvents/Info.plist b/RubyEvents/Info.plist index e0f24c0..59ad877 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,14 @@ + UIBackgroundModes + + audio + fetch + + BGTaskSchedulerPermittedIdentifiers + + org.rubyevents.RubyEvents.nextevent.refresh + 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..b430dae --- /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("/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/NextEventStore.swift b/RubyEvents/liveactivity/NextEventStore.swift new file mode 100644 index 0000000..dd767d6 --- /dev/null +++ b/RubyEvents/liveactivity/NextEventStore.swift @@ -0,0 +1,56 @@ +// +// NextEventStore.swift +// RubyEvents +// + +import Foundation +import UIKit + +struct NextEventSnapshot: Codable { + var name: String + var slug: String + var location: String? + var startAt: Date? + var endAt: Date? + var featuredBackground: String + var featuredColor: String + var bannerBackground: String + var speakersCount: Int + var participantsCount: Int + var featuredImageFile: String? + var avatarFiles: [String] + var upcoming: [UpcomingItem] +} + +struct UpcomingItem: Codable { + var name: String + var slug: String + var startAt: Date? + var featuredBackground: String + var featuredColor: String + var avatarFile: String? +} + +enum NextEventStore { + private static let filename = "next_event.json" + + static func save(_ snapshot: NextEventSnapshot) { + guard let url = LiveActivityShared.imageURL(for: filename), + let data = try? JSONEncoder().encode(snapshot) else { return } + + try? data.write(to: url, options: .atomic) + } + + static func load() -> NextEventSnapshot? { + guard let url = LiveActivityShared.imageURL(for: filename), + let data = try? Data(contentsOf: url) else { return nil } + + return try? JSONDecoder().decode(NextEventSnapshot.self, from: data) + } + + static func image(_ filename: String?) -> UIImage? { + guard let filename, let url = LiveActivityShared.imageURL(for: filename) else { return nil } + + return UIImage(contentsOfFile: url.path) + } +} diff --git a/RubyEvents/liveactivity/NextEventUpdater.swift b/RubyEvents/liveactivity/NextEventUpdater.swift new file mode 100644 index 0000000..f4ed7e7 --- /dev/null +++ b/RubyEvents/liveactivity/NextEventUpdater.swift @@ -0,0 +1,175 @@ +// +// NextEventUpdater.swift +// RubyEvents +// + +import BackgroundTasks +import Foundation +import UIKit +import WidgetKit + +enum NextEventUpdater { + static let backgroundTaskID = "org.rubyevents.RubyEvents.nextevent.refresh" + + static func refresh() { + Task { await performRefresh() } + } + + static func registerBackgroundTask() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: backgroundTaskID, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { return } + handle(refreshTask) + } + } + + static func scheduleBackgroundRefresh() { + let request = BGAppRefreshTaskRequest(identifier: backgroundTaskID) + request.earliestBeginDate = Date().addingTimeInterval(4 * 3600) // no sooner than ~4h + try? BGTaskScheduler.shared.submit(request) + } + + private static func handle(_ task: BGAppRefreshTask) { + scheduleBackgroundRefresh() + + let work = Task { + await performRefresh() + + task.setTaskCompleted(success: true) + } + + task.expirationHandler = { work.cancel() } + } + + private static func performRefresh() async { + struct Response: Decodable { + let event: Payload? + let upcoming: [UpcomingPayload]? + } + + struct Payload: Decodable { + let name: String + let slug: String + let location: String? + let start_at: String? + let end_at: String? + let featured_background: String? + let featured_color: String? + let banner_background: String? + let featured_url: String? + let keynote_avatars: [String]? + let speakers_count: Int? + let participants_count: Int? + } + + struct UpcomingPayload: Decodable { + let name: String + let slug: String + let start_at: String? + let featured_background: String? + let featured_color: String? + let avatar_url: String? + } + + let endpoint = Router.instance.root_url() + .appendingPathComponent("/hotwire/native/v1/next_event.json") + + print("πŸ“… NextEvent: container=\(LiveActivityShared.containerURL?.path ?? "nil") fetching=\(endpoint.absoluteString)") + + var fetchRequest = URLRequest(url: endpoint) + fetchRequest.timeoutInterval = 8 + + guard let (data, _) = try? await URLSession.shared.data(for: fetchRequest) else { + print("πŸ“… NextEvent: FETCH FAILED (host unreachable?)") + return + } + + guard let response = try? JSONDecoder().decode(Response.self, from: data), let event = response.event else { + print("πŸ“… NextEvent: DECODE/NO EVENT. body=\(String(data: data, encoding: .utf8)?.prefix(200) ?? "")") + return + } + + print("πŸ“… NextEvent: got '\(event.name)' β€” saving snapshot") + + async let featuredFile = saveImage(event.featured_url, as: "next-featured", maxDimension: 600) + async let avatarFiles = saveAvatars(event.keynote_avatars) + + var upcomingItems: [UpcomingItem] = [] + + for (index, item) in (response.upcoming ?? []).prefix(6).enumerated() { + let avatarFile = await saveImage(item.avatar_url, as: "upcoming-avatar-\(index)", maxDimension: 120) + + upcomingItems.append(UpcomingItem( + name: item.name, + slug: item.slug, + startAt: item.start_at.flatMap { ISO8601DateFormatter().date(from: $0) }, + featuredBackground: item.featured_background ?? "#000000", + featuredColor: item.featured_color ?? "#FFFFFF", + avatarFile: avatarFile + )) + } + + let snapshot = NextEventSnapshot( + name: event.name, + slug: event.slug, + location: event.location, + startAt: event.start_at.flatMap { ISO8601DateFormatter().date(from: $0) }, + endAt: event.end_at.flatMap { ISO8601DateFormatter().date(from: $0) }, + featuredBackground: event.featured_background ?? "#000000", + featuredColor: event.featured_color ?? "#FFFFFF", + bannerBackground: event.banner_background ?? "#081625", + speakersCount: event.speakers_count ?? 0, + participantsCount: event.participants_count ?? 0, + featuredImageFile: await featuredFile, + avatarFiles: await avatarFiles, + upcoming: upcomingItems + ) + + NextEventStore.save(snapshot) + WidgetCenter.shared.reloadAllTimelines() + print("πŸ“… NextEvent: saved (loaded back = \(NextEventStore.load() != nil)) + reloaded widgets") + } + + private static func saveAvatars(_ urls: [String]?) async -> [String] { + guard let urls else { return [] } + let slice = Array(urls.prefix(3)) + + return await withTaskGroup(of: (Int, String?).self) { group in + for (index, urlString) in slice.enumerated() { + group.addTask { (index, await saveImage(urlString, as: "next-avatar-\(index)", maxDimension: 120)) } + } + + var indexed: [(Int, String)] = [] + + for await (index, file) in group { + if let file { indexed.append((index, file)) } + } + + return indexed.sorted { $0.0 < $1.0 }.map { $0.1 } + } + } + + private static func saveImage(_ urlString: String?, as name: String, maxDimension: CGFloat) async -> String? { + guard let urlString, !urlString.isEmpty, let url = URL(string: urlString), + let destination = LiveActivityShared.imageURL(for: "\(name).png") else { return nil } + + var request = URLRequest(url: url) + request.timeoutInterval = 8 + + guard let (data, _) = try? await URLSession.shared.data(for: request), + 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 resized = UIGraphicsImageRenderer(size: size).image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } + + guard let png = resized.pngData() else { return nil } + + try? png.write(to: destination, options: .atomic) + + return "\(name).png" + } +} 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..a72b799 --- /dev/null +++ b/RubyEventsWidgets/AppIntent.swift @@ -0,0 +1,4 @@ +// +// AppIntent.swift +// RubyEventsWidgets +// 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/NextEventWidget.swift b/RubyEventsWidgets/NextEventWidget.swift new file mode 100644 index 0000000..a8dcb84 --- /dev/null +++ b/RubyEventsWidgets/NextEventWidget.swift @@ -0,0 +1,467 @@ +// +// NextEventWidget.swift +// RubyEventsWidgets +// + +import AppIntents +import SwiftUI +import UIKit +import WidgetKit + +enum NextEventDisplayMode: String, AppEnum { + case countdown + case list + + static var typeDisplayRepresentation: TypeDisplayRepresentation { + TypeDisplayRepresentation(name: "Display") + } + + static var caseDisplayRepresentations: [NextEventDisplayMode: DisplayRepresentation] { + [ + .countdown: DisplayRepresentation(title: "Countdown"), + .list: DisplayRepresentation(title: "Upcoming list") + ] + } +} + +struct ConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Next Ruby Event" } + + static var description: IntentDescription { + IntentDescription("Countdown to the next event, or a list of upcoming events (medium size).") + } + + @Parameter(title: "Display", default: NextEventDisplayMode.countdown) + var mode: NextEventDisplayMode +} + +struct NextEvent: Decodable { + let name: String + let slug: String + let location: String? + let start_at: String? + let end_at: String? + let featured_background: String? + let featured_color: String? + let banner_background: String? + let featured_url: String? + let keynote_avatars: [String]? + let speakers_count: Int? + let participants_count: Int? + + var startDate: Date? { start_at.flatMap { ISO8601DateFormatter().date(from: $0) } } + var endDate: Date? { end_at.flatMap { ISO8601DateFormatter().date(from: $0) } } +} + +struct UpcomingRow { + let name: String + let slug: String + let startDate: Date? + let featuredBackground: String + let featuredColor: String + let avatar: UIImage? +} + +struct NextEventEntry: TimelineEntry { + let date: Date + let event: NextEvent? + let image: UIImage? + let avatars: [UIImage] + let upcoming: [UpcomingRow] + let mode: NextEventDisplayMode +} + +struct NextEventProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> NextEventEntry { + NextEventEntry(date: Date(), event: .preview, image: nil, avatars: [], upcoming: [], mode: .countdown) + } + + func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> NextEventEntry { + currentEntry(mode: configuration.mode) + } + + func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { + let reload = Calendar.current.nextDate( + after: Date(), matching: DateComponents(hour: 0, minute: 5), matchingPolicy: .nextTime + ) ?? Date().addingTimeInterval(6 * 3600) + + return Timeline(entries: [currentEntry(mode: configuration.mode)], policy: .after(reload)) + } + + private func currentEntry(mode: NextEventDisplayMode) -> NextEventEntry { + guard let snapshot = NextEventStore.load() else { + return NextEventEntry(date: Date(), event: nil, image: nil, avatars: [], upcoming: [], mode: mode) + } + + let upcoming = snapshot.upcoming.map { item in + UpcomingRow( + name: item.name, + slug: item.slug, + startDate: item.startAt, + featuredBackground: item.featuredBackground, + featuredColor: item.featuredColor, + avatar: NextEventStore.image(item.avatarFile) + ) + } + + return NextEventEntry( + date: Date(), + event: NextEvent(snapshot), + image: NextEventStore.image(snapshot.featuredImageFile), + avatars: snapshot.avatarFiles.compactMap { NextEventStore.image($0) }, + upcoming: upcoming, + mode: mode + ) + } +} + +private func hexColor(_ hex: String?) -> Color { + guard let hex else { return .black } + let string = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex + + guard string.count == 6, let value = UInt64(string, radix: 16) else { return .black } + + return Color( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255 + ) +} + +struct NextEventWidgetView: View { + let entry: NextEventEntry + @SwiftUI.Environment(\.widgetFamily) private var family + + var body: some View { + if entry.mode == .list, !entry.upcoming.isEmpty { + listLayout() + } else if let event = entry.event, let start = event.startDate { + eventView(event, start) + } else { + emptyView + } + } + + private func listLayout() -> some View { + let isSmall = family == .systemSmall + let rows = Array(entry.upcoming.prefix(isSmall ? 5 : 3)) + + return VStack(alignment: .leading, spacing: isSmall ? 3 : 6) { + if !isSmall { + Text("UPCOMING RUBY EVENTS") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .padding(.bottom, 4) + } + + ForEach(rows.indices, id: \.self) { i in + if i > 0 { Divider() } + upcomingRow(rows[i], compact: isSmall) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .modifier(WidgetContainerBackground(color: Color(.systemBackground), contentPadding: isSmall ? 11 : 14)) + } + + private func upcomingRow(_ row: UpcomingRow, compact: Bool) -> some View { + let accent = hexColor(row.featuredBackground) + let size: CGFloat = compact ? 23 : 30 + + let days = row.startDate.map { + max(0, Calendar.current.dateComponents( + [.day], from: Calendar.current.startOfDay(for: Date()), to: Calendar.current.startOfDay(for: $0) + ).day ?? 0) + } + + return Link(destination: URL(string: "rubyevents://events/\(row.slug)")!) { + HStack(spacing: compact ? 8 : 10) { + Group { + if let avatar = row.avatar { + Image(uiImage: avatar).resizable().scaledToFill() + } else { + accent.overlay(Image(systemName: "calendar").font(.caption2).foregroundStyle(.white)) + } + } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: compact ? 6 : 7)) + + Text(row.name) + .font((compact ? Font.caption : Font.subheadline).weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.8) + + Spacer(minLength: 4) + + if let days { + if compact { + Text("\(days)d") + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(.primary) + } else { + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text("\(days)").font(.system(size: 18, weight: .bold, design: .rounded)).foregroundStyle(.primary) + Text(days == 1 ? "day" : "days").font(.caption2).foregroundStyle(.secondary) + } + } + } + } + } + } + + private func eventView(_ event: NextEvent, _ start: Date) -> some View { + let foreground = hexColor(event.featured_color) + + return Group { + if family == .systemSmall { + smallLayout(event, start, foreground) + } else { + mediumLayout(event, start, foreground) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .widgetURL(URL(string: "rubyevents://events/\(event.slug)")) + .modifier(WidgetContainerBackground( + color: hexColor(event.featured_background), + image: family == .systemSmall ? nil : entry.image, + imageWidth: 152, + imageHeight: 88, + contentPadding: family == .systemSmall ? 12 : 16 + )) + } + + private func smallLayout(_ event: NextEvent, _ start: Date, _ fg: Color) -> some View { + VStack(alignment: .leading, spacing: 2) { + Spacer(minLength: 0) + Text(event.name) + .font(.headline) + .foregroundStyle(fg) + .lineLimit(1) + .minimumScaleFactor(0.7) + + if let location = shortLocation(event.location) { + Text(location) + .font(.caption) + .foregroundStyle(fg.opacity(0.7)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + Spacer().frame(height: 10) + countdown(to: start, foreground: fg, compact: true) + dateLabel(start, event.endDate, fg) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func shortLocation(_ location: String?) -> String? { + guard let location, !location.isEmpty else { return nil } + + return location.split(separator: ",").prefix(2) + .map { $0.trimmingCharacters(in: .whitespaces) } + .joined(separator: ", ") + } + + private func mediumLayout(_ event: NextEvent, _ start: Date, _ fg: Color) -> some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("NEXT RUBY EVENT").font(.caption2.weight(.bold)).foregroundStyle(fg.opacity(0.7)) + Text(event.name).font(.headline).foregroundStyle(fg).lineLimit(2) + locationLabel(event, fg) + Spacer(minLength: 6) + countdown(to: start, foreground: fg, compact: false) + dateLabel(start, event.endDate, fg) + } + + if !entry.avatars.isEmpty || (event.participants_count ?? 0) > 0 { + Spacer(minLength: 0) + attendeePanel(event, fg) + } + } + } + + @ViewBuilder + private func locationLabel(_ event: NextEvent, _ fg: Color) -> some View { + if let location = event.location, !location.isEmpty { + Text(location).font(.caption).foregroundStyle(fg.opacity(0.7)).lineLimit(1) + } + } + + private func dateLabel(_ start: Date, _ end: Date?, _ fg: Color) -> some View { + Text(dateRangeText(start, end)) + .font(.caption2) + .foregroundStyle(fg.opacity(0.7)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + private func dateRangeText(_ start: Date, _ end: Date?) -> String { + if let end, !Calendar.current.isDate(start, inSameDayAs: end) { + let formatter = DateIntervalFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .none + + return formatter.string(from: start, to: end) + } + + return start.formatted(.dateTime.month(.wide).day().year()) + } + + private func countdown(to start: Date, foreground fg: Color, compact: Bool) -> some View { + let days = max(0, Calendar.current.dateComponents( + [.day], from: Calendar.current.startOfDay(for: Date()), to: Calendar.current.startOfDay(for: start) + ).day ?? 0) + + return Group { + if days == 0 { + Text("Today").font(.system(size: 30, weight: .bold, design: .rounded)).foregroundStyle(fg) + } else if compact { + VStack(alignment: .leading, spacing: -4) { + Text("\(days)").font(.system(size: 48, weight: .bold, design: .rounded)).foregroundStyle(fg) + Text(days == 1 ? "DAY TO GO" : "DAYS TO GO") + .font(.caption2.weight(.semibold)).foregroundStyle(fg.opacity(0.75)) + } + } else { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("\(days)").font(.system(size: 44, weight: .bold, design: .rounded)).foregroundStyle(fg) + Text(days == 1 ? "day to go" : "days to go") + .font(.subheadline.weight(.medium)).foregroundStyle(fg.opacity(0.75)) + } + } + } + } + + private func attendeePanel(_ event: NextEvent, _ fg: Color) -> some View { + let extra = max(0, (event.participants_count ?? 0) - entry.avatars.count) + + return VStack(alignment: .trailing, spacing: 6) { + Spacer(minLength: 0) + avatarStack(extra: extra, fg: fg, bg: hexColor(event.featured_background)) + Text("known participants") + .font(.caption2).foregroundStyle(fg.opacity(0.7)).lineLimit(1) + } + } + + private func avatarStack(extra: Int, fg: Color, bg: Color) -> some View { + let diameter: CGFloat = 34 + + return HStack(spacing: -11) { + ForEach(entry.avatars.indices, id: \.self) { i in + Image(uiImage: entry.avatars[i]).resizable().scaledToFill() + .frame(width: diameter, height: diameter) + .clipShape(Circle()) + .overlay(Circle().strokeBorder(fg.opacity(0.9), lineWidth: 1.5)) + } + + if extra > 0 { + Text("+\(extra)") + .font(.caption2.weight(.bold)) + .foregroundStyle(bg) + .frame(width: diameter, height: diameter) + .background(Circle().fill(fg)) + .overlay(Circle().strokeBorder(bg, lineWidth: 1.5)) + } + } + } + + private var emptyView: some View { + VStack(spacing: 4) { + Image(systemName: "calendar") + .font(.title) + .foregroundStyle(.secondary) + + Text("No upcoming events") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + Text("grp: \(LiveActivityShared.containerURL?.lastPathComponent.prefix(8) ?? "nil")") + .font(.system(size: 9)).foregroundStyle(.secondary) + + Text("store: \(NextEventStore.load() != nil ? "found" : "missing")") + .font(.system(size: 9)).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .modifier(WidgetContainerBackground(color: Color(.systemBackground))) + } +} + +private struct WidgetContainerBackground: ViewModifier { + let color: Color + var image: UIImage? + var imageWidth: CGFloat = 120 + var imageHeight: CGFloat = 72 + var contentPadding: CGFloat = 16 + + @ViewBuilder private var backgroundView: some View { + color.overlay(alignment: .topTrailing) { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: imageWidth, maxHeight: imageHeight) + .padding(.top, 12) + .padding(.trailing, 10) + } + } + } + + func body(content: Content) -> some View { + if #available(iOS 17.0, *) { + content.padding(contentPadding).containerBackground(for: .widget) { backgroundView } + } else { + content.padding(contentPadding).background(backgroundView) + } + } +} + +struct NextEventWidget: Widget { + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: "NextEventWidget", intent: ConfigurationAppIntent.self, provider: NextEventProvider()) { entry in + NextEventWidgetView(entry: entry) + } + .configurationDisplayName("Next Ruby Event") + .description("Countdown to the next Ruby event, or a list of upcoming ones (medium).") + .supportedFamilies([.systemSmall, .systemMedium]) + .contentMarginsDisabled() + } +} + +extension NextEvent { + init(_ snapshot: NextEventSnapshot) { + self.init( + name: snapshot.name, + slug: snapshot.slug, + location: snapshot.location, + start_at: snapshot.startAt.map { ISO8601DateFormatter().string(from: $0) }, + end_at: snapshot.endAt.map { ISO8601DateFormatter().string(from: $0) }, + featured_background: snapshot.featuredBackground, + featured_color: snapshot.featuredColor, + banner_background: snapshot.bannerBackground, + featured_url: nil, + keynote_avatars: nil, + speakers_count: snapshot.speakersCount, + participants_count: snapshot.participantsCount + ) + } + + static var preview: NextEvent { + NextEvent( + name: "RubyConf 2026", + slug: "rubyconf-2026", + location: "Las Vegas, NV", + start_at: "2026-07-14T09:00:00-07:00", + end_at: "2026-07-16T09:00:00-07:00", + featured_background: "#303A32", + featured_color: "#E3ECDB", + banner_background: "#303A32", + featured_url: nil, + keynote_avatars: [], + speakers_count: 62, + participants_count: 68 + ) + } +} diff --git a/RubyEventsWidgets/RubyEventsWidgets.swift b/RubyEventsWidgets/RubyEventsWidgets.swift new file mode 100644 index 0000000..cc645c4 --- /dev/null +++ b/RubyEventsWidgets/RubyEventsWidgets.swift @@ -0,0 +1,8 @@ +// +// RubyEventsWidgets.swift +// RubyEventsWidgets +// +// The Xcode sample widget (time / favorite emoji) has been removed. +// The active widgets are `NextEventWidget` and `RubyEventsWidgetsLiveActivity` +// (registered in RubyEventsWidgetsBundle). +// diff --git a/RubyEventsWidgets/RubyEventsWidgetsBundle.swift b/RubyEventsWidgets/RubyEventsWidgetsBundle.swift new file mode 100644 index 0000000..05bab45 --- /dev/null +++ b/RubyEventsWidgets/RubyEventsWidgetsBundle.swift @@ -0,0 +1,17 @@ +// +// RubyEventsWidgetsBundle.swift +// RubyEventsWidgets +// +// Created by Marco Roth on 09.07.2026. +// + +import WidgetKit +import SwiftUI + +@main +struct RubyEventsWidgetsBundle: WidgetBundle { + var body: some Widget { + NextEventWidget() + 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 + + +