├── .gitignore ├── README.md ├── RubyEvents.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcuserdata │ │ └── marcoroth.xcuserdatad │ │ └── UserInterfaceState.xcuserstate ├── xcshareddata │ └── xcschemes │ │ └── RubyEvents.xcscheme └── xcuserdata │ └── marcoroth.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist └── RubyEvents ├── App.swift ├── AppDelegate.swift ├── Assets.xcassets ├── AccentColor.colorset │ └── Contents.json ├── AppIcon.appiconset │ ├── AppIcon.png │ └── Contents.json ├── BrandRed.colorset │ └── Contents.json ├── Contents.json └── LaunchScreen.imageset │ ├── Contents.json │ ├── LaunchScreen.png │ ├── LaunchScreen@2x.png │ └── LaunchScreen@3x.png ├── Base.lproj ├── LaunchScreen.storyboard └── Main.storyboard ├── Info.plist ├── SceneDelegate.swift ├── api ├── APIService.swift └── HomeViewResponse.swift ├── bridge ├── ButtonComponent.swift └── LargeTitleComponent.swift ├── components ├── avatar │ ├── Avatar.swift │ ├── ImageAvatar.swift │ └── InitialsAvatar.swift ├── cards │ ├── EventCard.swift │ ├── FeaturedCard.swift │ └── TalkCard.swift └── carousel │ ├── EventCarousel.swift │ ├── FeaturedCarousel.swift │ ├── SpeakerCarousel.swift │ └── TalkCarousel.swift ├── controllers ├── PageViewController.swift └── TabBarController.swift ├── enums └── Environment.swift ├── lib ├── Color.swift ├── Endpoint.swift ├── FixedTabBarItem.swift └── Router.swift ├── models ├── Event.swift ├── Speaker.swift └── Talk.swift ├── path-configuration.json └── views ├── HomeView.swift ├── HomeViewSkeleton.swift └── PageView.swift /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | 3 | # Mac OS X 4 | *.DS_Store 5 | 6 | # Xcode 7 | *.pbxuser 8 | *.mode1v3 9 | *.mode2v3 10 | *.perspectivev3 11 | *.xcuserstate 12 | project.xcworkspace/ 13 | xcuserdata/ 14 | 15 | # Generated files 16 | *.o 17 | *.pyc 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [RubyEvents.org](https://rubyevents.org) Hotwire Native iOS App 2 | 3 | Read more about Hotwire in the [Hotwire Native Documentation](https://native.hotwired.dev). 4 | 5 | 6 | ### Download the App from the App Store 7 | 8 | https://apps.apple.com/app/rubyevents/id6743987375 9 | -------------------------------------------------------------------------------- /RubyEvents.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 973DD3652D95F8BE008E0AED /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 973DD3642D95F8BE008E0AED /* CachedAsyncImage */; }; 11 | 97613B4C2D0635C600220031 /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 97613B4B2D0635C600220031 /* HotwireNative */; }; 12 | 97613B572D06512900220031 /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 97613B562D06512900220031 /* YouTubePlayerKit */; }; 13 | /* End PBXBuildFile section */ 14 | 15 | /* Begin PBXFileReference section */ 16 | 97613B162D0634F500220031 /* RubyEvents.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RubyEvents.app; sourceTree = BUILT_PRODUCTS_DIR; }; 17 | /* End PBXFileReference section */ 18 | 19 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 20 | 97613B3E2D0634FA00220031 /* Exceptions for "RubyEvents" folder in "RubyEvents" target */ = { 21 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 22 | membershipExceptions = ( 23 | Info.plist, 24 | ); 25 | target = 97613B152D0634F500220031 /* RubyEvents */; 26 | }; 27 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 28 | 29 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 30 | 97613B182D0634F500220031 /* RubyEvents */ = { 31 | isa = PBXFileSystemSynchronizedRootGroup; 32 | exceptions = ( 33 | 97613B3E2D0634FA00220031 /* Exceptions for "RubyEvents" folder in "RubyEvents" target */, 34 | ); 35 | path = RubyEvents; 36 | sourceTree = ""; 37 | }; 38 | /* End PBXFileSystemSynchronizedRootGroup section */ 39 | 40 | /* Begin PBXFrameworksBuildPhase section */ 41 | 97613B132D0634F500220031 /* Frameworks */ = { 42 | isa = PBXFrameworksBuildPhase; 43 | buildActionMask = 2147483647; 44 | files = ( 45 | 97613B572D06512900220031 /* YouTubePlayerKit in Frameworks */, 46 | 973DD3652D95F8BE008E0AED /* CachedAsyncImage in Frameworks */, 47 | 97613B4C2D0635C600220031 /* HotwireNative in Frameworks */, 48 | ); 49 | runOnlyForDeploymentPostprocessing = 0; 50 | }; 51 | /* End PBXFrameworksBuildPhase section */ 52 | 53 | /* Begin PBXGroup section */ 54 | 97613B0D2D0634F500220031 = { 55 | isa = PBXGroup; 56 | children = ( 57 | 97613B182D0634F500220031 /* RubyEvents */, 58 | 97613B172D0634F500220031 /* Products */, 59 | ); 60 | sourceTree = ""; 61 | }; 62 | 97613B172D0634F500220031 /* Products */ = { 63 | isa = PBXGroup; 64 | children = ( 65 | 97613B162D0634F500220031 /* RubyEvents.app */, 66 | ); 67 | name = Products; 68 | sourceTree = ""; 69 | }; 70 | /* End PBXGroup section */ 71 | 72 | /* Begin PBXNativeTarget section */ 73 | 97613B152D0634F500220031 /* RubyEvents */ = { 74 | isa = PBXNativeTarget; 75 | buildConfigurationList = 97613B3F2D0634FA00220031 /* Build configuration list for PBXNativeTarget "RubyEvents" */; 76 | buildPhases = ( 77 | 97613B122D0634F500220031 /* Sources */, 78 | 97613B132D0634F500220031 /* Frameworks */, 79 | 97613B142D0634F500220031 /* Resources */, 80 | ); 81 | buildRules = ( 82 | ); 83 | dependencies = ( 84 | ); 85 | fileSystemSynchronizedGroups = ( 86 | 97613B182D0634F500220031 /* RubyEvents */, 87 | ); 88 | name = RubyEvents; 89 | packageProductDependencies = ( 90 | 97613B4B2D0635C600220031 /* HotwireNative */, 91 | 97613B562D06512900220031 /* YouTubePlayerKit */, 92 | 973DD3642D95F8BE008E0AED /* CachedAsyncImage */, 93 | ); 94 | productName = RubyEvents; 95 | productReference = 97613B162D0634F500220031 /* RubyEvents.app */; 96 | productType = "com.apple.product-type.application"; 97 | }; 98 | /* End PBXNativeTarget section */ 99 | 100 | /* Begin PBXProject section */ 101 | 97613B0E2D0634F500220031 /* Project object */ = { 102 | isa = PBXProject; 103 | attributes = { 104 | BuildIndependentTargetsInParallel = 1; 105 | LastSwiftUpdateCheck = 1610; 106 | LastUpgradeCheck = 1610; 107 | TargetAttributes = { 108 | 97613B152D0634F500220031 = { 109 | CreatedOnToolsVersion = 16.1; 110 | }; 111 | }; 112 | }; 113 | buildConfigurationList = 97613B112D0634F500220031 /* Build configuration list for PBXProject "RubyEvents" */; 114 | developmentRegion = en; 115 | hasScannedForEncodings = 0; 116 | knownRegions = ( 117 | en, 118 | Base, 119 | ); 120 | mainGroup = 97613B0D2D0634F500220031; 121 | minimizedProjectReferenceProxies = 1; 122 | packageReferences = ( 123 | 97613B4A2D0635C600220031 /* XCRemoteSwiftPackageReference "hotwire-native-ios" */, 124 | 97613B552D06512900220031 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */, 125 | 973DD3632D95F8BE008E0AED /* XCRemoteSwiftPackageReference "CachedAsyncImage" */, 126 | ); 127 | preferredProjectObjectVersion = 77; 128 | productRefGroup = 97613B172D0634F500220031 /* Products */; 129 | projectDirPath = ""; 130 | projectRoot = ""; 131 | targets = ( 132 | 97613B152D0634F500220031 /* RubyEvents */, 133 | ); 134 | }; 135 | /* End PBXProject section */ 136 | 137 | /* Begin PBXResourcesBuildPhase section */ 138 | 97613B142D0634F500220031 /* Resources */ = { 139 | isa = PBXResourcesBuildPhase; 140 | buildActionMask = 2147483647; 141 | files = ( 142 | ); 143 | runOnlyForDeploymentPostprocessing = 0; 144 | }; 145 | /* End PBXResourcesBuildPhase section */ 146 | 147 | /* Begin PBXSourcesBuildPhase section */ 148 | 97613B122D0634F500220031 /* Sources */ = { 149 | isa = PBXSourcesBuildPhase; 150 | buildActionMask = 2147483647; 151 | files = ( 152 | ); 153 | runOnlyForDeploymentPostprocessing = 0; 154 | }; 155 | /* End PBXSourcesBuildPhase section */ 156 | 157 | /* Begin XCBuildConfiguration section */ 158 | 97613B402D0634FA00220031 /* Debug */ = { 159 | isa = XCBuildConfiguration; 160 | buildSettings = { 161 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 162 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 163 | ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; 164 | CODE_SIGN_STYLE = Automatic; 165 | CURRENT_PROJECT_VERSION = 1; 166 | DEVELOPMENT_TEAM = 5UY2R75SM3; 167 | GENERATE_INFOPLIST_FILE = YES; 168 | INFOPLIST_FILE = RubyEvents/Info.plist; 169 | INFOPLIST_KEY_CFBundleDisplayName = RubyEvents; 170 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; 171 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 172 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 173 | INFOPLIST_KEY_UIMainStoryboardFile = Main; 174 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 175 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 176 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 177 | LD_RUNPATH_SEARCH_PATHS = ( 178 | "$(inherited)", 179 | "@executable_path/Frameworks", 180 | ); 181 | MARKETING_VERSION = 0.2.0; 182 | PRODUCT_BUNDLE_IDENTIFIER = org.rubyevents.RubyEvents; 183 | PRODUCT_NAME = "$(TARGET_NAME)"; 184 | SWIFT_EMIT_LOC_STRINGS = YES; 185 | SWIFT_VERSION = 5.0; 186 | TARGETED_DEVICE_FAMILY = "1,2"; 187 | }; 188 | name = Debug; 189 | }; 190 | 97613B412D0634FA00220031 /* Release */ = { 191 | isa = XCBuildConfiguration; 192 | buildSettings = { 193 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 194 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 195 | ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; 196 | CODE_SIGN_STYLE = Automatic; 197 | CURRENT_PROJECT_VERSION = 1; 198 | DEVELOPMENT_TEAM = 5UY2R75SM3; 199 | GENERATE_INFOPLIST_FILE = YES; 200 | INFOPLIST_FILE = RubyEvents/Info.plist; 201 | INFOPLIST_KEY_CFBundleDisplayName = RubyEvents; 202 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; 203 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 204 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 205 | INFOPLIST_KEY_UIMainStoryboardFile = Main; 206 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 207 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 208 | IPHONEOS_DEPLOYMENT_TARGET = 16.0; 209 | LD_RUNPATH_SEARCH_PATHS = ( 210 | "$(inherited)", 211 | "@executable_path/Frameworks", 212 | ); 213 | MARKETING_VERSION = 0.2.0; 214 | PRODUCT_BUNDLE_IDENTIFIER = org.rubyevents.RubyEvents; 215 | PRODUCT_NAME = "$(TARGET_NAME)"; 216 | SWIFT_EMIT_LOC_STRINGS = YES; 217 | SWIFT_VERSION = 5.0; 218 | TARGETED_DEVICE_FAMILY = "1,2"; 219 | }; 220 | name = Release; 221 | }; 222 | 97613B422D0634FA00220031 /* Debug */ = { 223 | isa = XCBuildConfiguration; 224 | buildSettings = { 225 | ALWAYS_SEARCH_USER_PATHS = NO; 226 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 227 | CLANG_ANALYZER_NONNULL = YES; 228 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 229 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 230 | CLANG_ENABLE_MODULES = YES; 231 | CLANG_ENABLE_OBJC_ARC = YES; 232 | CLANG_ENABLE_OBJC_WEAK = YES; 233 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 234 | CLANG_WARN_BOOL_CONVERSION = YES; 235 | CLANG_WARN_COMMA = YES; 236 | CLANG_WARN_CONSTANT_CONVERSION = YES; 237 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 238 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 239 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 240 | CLANG_WARN_EMPTY_BODY = YES; 241 | CLANG_WARN_ENUM_CONVERSION = YES; 242 | CLANG_WARN_INFINITE_RECURSION = YES; 243 | CLANG_WARN_INT_CONVERSION = YES; 244 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 245 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 246 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 247 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 248 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 249 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 250 | CLANG_WARN_STRICT_PROTOTYPES = YES; 251 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 252 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 253 | CLANG_WARN_UNREACHABLE_CODE = YES; 254 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 255 | COPY_PHASE_STRIP = NO; 256 | DEBUG_INFORMATION_FORMAT = dwarf; 257 | ENABLE_STRICT_OBJC_MSGSEND = YES; 258 | ENABLE_TESTABILITY = YES; 259 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 260 | GCC_C_LANGUAGE_STANDARD = gnu17; 261 | GCC_DYNAMIC_NO_PIC = NO; 262 | GCC_NO_COMMON_BLOCKS = YES; 263 | GCC_OPTIMIZATION_LEVEL = 0; 264 | GCC_PREPROCESSOR_DEFINITIONS = ( 265 | "DEBUG=1", 266 | "$(inherited)", 267 | ); 268 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 269 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 270 | GCC_WARN_UNDECLARED_SELECTOR = YES; 271 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 272 | GCC_WARN_UNUSED_FUNCTION = YES; 273 | GCC_WARN_UNUSED_VARIABLE = YES; 274 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 275 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 276 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 277 | MTL_FAST_MATH = YES; 278 | ONLY_ACTIVE_ARCH = YES; 279 | SDKROOT = iphoneos; 280 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 281 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 282 | }; 283 | name = Debug; 284 | }; 285 | 97613B432D0634FA00220031 /* Release */ = { 286 | isa = XCBuildConfiguration; 287 | buildSettings = { 288 | ALWAYS_SEARCH_USER_PATHS = NO; 289 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 290 | CLANG_ANALYZER_NONNULL = YES; 291 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 292 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 293 | CLANG_ENABLE_MODULES = YES; 294 | CLANG_ENABLE_OBJC_ARC = YES; 295 | CLANG_ENABLE_OBJC_WEAK = YES; 296 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 297 | CLANG_WARN_BOOL_CONVERSION = YES; 298 | CLANG_WARN_COMMA = YES; 299 | CLANG_WARN_CONSTANT_CONVERSION = YES; 300 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 301 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 302 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 303 | CLANG_WARN_EMPTY_BODY = YES; 304 | CLANG_WARN_ENUM_CONVERSION = YES; 305 | CLANG_WARN_INFINITE_RECURSION = YES; 306 | CLANG_WARN_INT_CONVERSION = YES; 307 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 308 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 309 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 310 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 311 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 312 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 313 | CLANG_WARN_STRICT_PROTOTYPES = YES; 314 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 315 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 316 | CLANG_WARN_UNREACHABLE_CODE = YES; 317 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 318 | COPY_PHASE_STRIP = NO; 319 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 320 | ENABLE_NS_ASSERTIONS = NO; 321 | ENABLE_STRICT_OBJC_MSGSEND = YES; 322 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 323 | GCC_C_LANGUAGE_STANDARD = gnu17; 324 | GCC_NO_COMMON_BLOCKS = YES; 325 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 326 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 327 | GCC_WARN_UNDECLARED_SELECTOR = YES; 328 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 329 | GCC_WARN_UNUSED_FUNCTION = YES; 330 | GCC_WARN_UNUSED_VARIABLE = YES; 331 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 332 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 333 | MTL_ENABLE_DEBUG_INFO = NO; 334 | MTL_FAST_MATH = YES; 335 | SDKROOT = iphoneos; 336 | SWIFT_COMPILATION_MODE = wholemodule; 337 | VALIDATE_PRODUCT = YES; 338 | }; 339 | name = Release; 340 | }; 341 | /* End XCBuildConfiguration section */ 342 | 343 | /* Begin XCConfigurationList section */ 344 | 97613B112D0634F500220031 /* Build configuration list for PBXProject "RubyEvents" */ = { 345 | isa = XCConfigurationList; 346 | buildConfigurations = ( 347 | 97613B422D0634FA00220031 /* Debug */, 348 | 97613B432D0634FA00220031 /* Release */, 349 | ); 350 | defaultConfigurationIsVisible = 0; 351 | defaultConfigurationName = Release; 352 | }; 353 | 97613B3F2D0634FA00220031 /* Build configuration list for PBXNativeTarget "RubyEvents" */ = { 354 | isa = XCConfigurationList; 355 | buildConfigurations = ( 356 | 97613B402D0634FA00220031 /* Debug */, 357 | 97613B412D0634FA00220031 /* Release */, 358 | ); 359 | defaultConfigurationIsVisible = 0; 360 | defaultConfigurationName = Release; 361 | }; 362 | /* End XCConfigurationList section */ 363 | 364 | /* Begin XCRemoteSwiftPackageReference section */ 365 | 973DD3632D95F8BE008E0AED /* XCRemoteSwiftPackageReference "CachedAsyncImage" */ = { 366 | isa = XCRemoteSwiftPackageReference; 367 | repositoryURL = "https://github.com/bullinnyc/CachedAsyncImage"; 368 | requirement = { 369 | kind = upToNextMajorVersion; 370 | minimumVersion = 2.6.0; 371 | }; 372 | }; 373 | 97613B4A2D0635C600220031 /* XCRemoteSwiftPackageReference "hotwire-native-ios" */ = { 374 | isa = XCRemoteSwiftPackageReference; 375 | repositoryURL = "https://github.com/hotwired/hotwire-native-ios"; 376 | requirement = { 377 | kind = upToNextMajorVersion; 378 | minimumVersion = 1.0.1; 379 | }; 380 | }; 381 | 97613B552D06512900220031 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */ = { 382 | isa = XCRemoteSwiftPackageReference; 383 | repositoryURL = "https://github.com/SvenTiigi/YouTubePlayerKit.git"; 384 | requirement = { 385 | kind = upToNextMajorVersion; 386 | minimumVersion = 1.9.0; 387 | }; 388 | }; 389 | /* End XCRemoteSwiftPackageReference section */ 390 | 391 | /* Begin XCSwiftPackageProductDependency section */ 392 | 973DD3642D95F8BE008E0AED /* CachedAsyncImage */ = { 393 | isa = XCSwiftPackageProductDependency; 394 | package = 973DD3632D95F8BE008E0AED /* XCRemoteSwiftPackageReference "CachedAsyncImage" */; 395 | productName = CachedAsyncImage; 396 | }; 397 | 97613B4B2D0635C600220031 /* HotwireNative */ = { 398 | isa = XCSwiftPackageProductDependency; 399 | package = 97613B4A2D0635C600220031 /* XCRemoteSwiftPackageReference "hotwire-native-ios" */; 400 | productName = HotwireNative; 401 | }; 402 | 97613B562D06512900220031 /* YouTubePlayerKit */ = { 403 | isa = XCSwiftPackageProductDependency; 404 | package = 97613B552D06512900220031 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */; 405 | productName = YouTubePlayerKit; 406 | }; 407 | /* End XCSwiftPackageProductDependency section */ 408 | }; 409 | rootObject = 97613B0E2D0634F500220031 /* Project object */; 410 | } 411 | -------------------------------------------------------------------------------- /RubyEvents.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /RubyEvents.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "c7e739a9235f7569c4841d8050af06b35acc10a3591a95cc1c3d4e2f75e46cb2", 3 | "pins" : [ 4 | { 5 | "identity" : "cachedasyncimage", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/bullinnyc/CachedAsyncImage", 8 | "state" : { 9 | "revision" : "cb253e111528c082381af54b67dab7a15eefde16", 10 | "version" : "2.6.0" 11 | } 12 | }, 13 | { 14 | "identity" : "hotwire-native-ios", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/hotwired/hotwire-native-ios", 17 | "state" : { 18 | "revision" : "56196ac91a63a619ef13e8d2c135b6346b541192", 19 | "version" : "1.1.3" 20 | } 21 | }, 22 | { 23 | "identity" : "youtubeplayerkit", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/SvenTiigi/YouTubePlayerKit.git", 26 | "state" : { 27 | "revision" : "fe1c1ec340f6d79866131432ecaa190fd6bbc4cb", 28 | "version" : "1.9.0" 29 | } 30 | } 31 | ], 32 | "version" : 3 33 | } 34 | -------------------------------------------------------------------------------- /RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubyevents/rubyevents-ios/207b47dacd7a89c3bbdf95fd3cd2890624657473/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /RubyEvents.xcodeproj/xcshareddata/xcschemes/RubyEvents.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /RubyEvents.xcodeproj/xcuserdata/marcoroth.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | RubyEvents.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /RubyEvents/App.swift: -------------------------------------------------------------------------------- 1 | // 2 | // App.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 17.01.2025. 6 | // 7 | 8 | import HotwireNative 9 | import SwiftUI 10 | import UIKit 11 | 12 | class App { 13 | static var instance = App() 14 | 15 | var isTabbed: Bool = true 16 | 17 | var sceneDelegate: SceneDelegate? 18 | 19 | lazy var navigator = Navigator(delegate: self) 20 | lazy var tabBarController = TabBarController(app: self) 21 | 22 | var navigators: [Navigator] { 23 | if isTabbed { 24 | return tabBarController.navigators 25 | } 26 | return [navigator] 27 | } 28 | 29 | var viewControllers: [UIViewController] { 30 | navigators.map(\.rootViewController) 31 | } 32 | 33 | var window: UIWindow? { 34 | sceneDelegate?.window 35 | } 36 | 37 | var isDebug: Bool { 38 | #if DEBUG 39 | return true 40 | #else 41 | return false 42 | #endif 43 | } 44 | 45 | var isTestFlight: Bool { 46 | Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" 47 | } 48 | 49 | var environment: Environment { 50 | if isDebug { 51 | return .development 52 | } 53 | 54 | if isTestFlight { 55 | return .staging 56 | } 57 | 58 | return .production 59 | } 60 | 61 | func start(sceneDelegate: SceneDelegate) { 62 | self.sceneDelegate = sceneDelegate 63 | self.tabBarController.setupTabs() 64 | 65 | switchToTabController() 66 | } 67 | 68 | func switchToNavigationController() { 69 | sceneDelegate?.window?.rootViewController = navigator.rootViewController 70 | self.isTabbed = false 71 | } 72 | 73 | func switchToTabController() { 74 | sceneDelegate?.window?.rootViewController = tabBarController 75 | self.isTabbed = true 76 | } 77 | 78 | func navigatorFor(title: String) -> Navigator? { 79 | tabBarController.navigatorFor(title: title) 80 | } 81 | } 82 | 83 | extension App: NavigatorDelegate { 84 | func handle(proposal: VisitProposal) -> ProposalResult { 85 | switch proposal.viewController { 86 | case "home": 87 | let viewController = UIHostingController( 88 | rootView: HomeView( 89 | navigator: App.instance.navigatorFor(title: "Home") 90 | ) 91 | ) 92 | 93 | App.instance.tabBarController.hideNavigationBarFor(title: "Home") 94 | 95 | return .acceptCustom(viewController) 96 | default: 97 | App.instance.tabBarController.showNavigationBarFor(title: "Home") 98 | 99 | return .accept 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /RubyEvents/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 06.01.2025. 6 | // 7 | 8 | import HotwireNative 9 | import UIKit 10 | import WebKit 11 | 12 | @main 13 | class AppDelegate: UIResponder, UIApplicationDelegate { 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | 16 | let versionNumber = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String 17 | let uniqueDeviceId = UIDevice.current.identifierForVendor?.uuidString ?? "" 18 | 19 | Hotwire.config.applicationUserAgentPrefix = "Hotwire Native iOS; app_version: \(versionNumber); unique_device_id: \(uniqueDeviceId);" 20 | 21 | Hotwire.registerBridgeComponents([ 22 | ButtonComponent.self 23 | ]) 24 | 25 | Hotwire.config.showDoneButtonOnModals = true 26 | Hotwire.config.debugLoggingEnabled = true 27 | 28 | Hotwire.loadPathConfiguration(from: [ 29 | .server(Router.instance.path_configuration_url()), 30 | .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!) 31 | ]) 32 | 33 | Hotwire.config.makeCustomWebView = { config in 34 | let webView = WKWebView(frame: .zero, configuration: config) 35 | webView.allowsLinkPreview = false 36 | return webView 37 | } 38 | 39 | return true 40 | } 41 | 42 | // MARK: UISceneSession Lifecycle 43 | 44 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 45 | // Called when a new scene session is being created. 46 | // Use this method to select a configuration to create the new scene with. 47 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 48 | } 49 | 50 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 51 | // Called when the user discards a scene session. 52 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 53 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /RubyEvents/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.236", 9 | "green" : "0.081", 10 | "red" : "0.865" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /RubyEvents/Assets.xcassets/AppIcon.appiconset/AppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubyevents/rubyevents-ios/207b47dacd7a89c3bbdf95fd3cd2890624657473/RubyEvents/Assets.xcassets/AppIcon.appiconset/AppIcon.png -------------------------------------------------------------------------------- /RubyEvents/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "idiom" : "universal", 17 | "platform" : "ios", 18 | "size" : "1024x1024" 19 | }, 20 | { 21 | "appearances" : [ 22 | { 23 | "appearance" : "luminosity", 24 | "value" : "tinted" 25 | } 26 | ], 27 | "idiom" : "universal", 28 | "platform" : "ios", 29 | "size" : "1024x1024" 30 | } 31 | ], 32 | "info" : { 33 | "author" : "xcode", 34 | "version" : 1 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /RubyEvents/Assets.xcassets/BrandRed.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.236", 9 | "green" : "0.081", 10 | "red" : "0.865" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.236", 27 | "green" : "0.081", 28 | "red" : "0.865" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /RubyEvents/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /RubyEvents/Assets.xcassets/LaunchScreen.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "LaunchScreen.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "LaunchScreen@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "LaunchScreen@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /RubyEvents/Assets.xcassets/LaunchScreen.imageset/LaunchScreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubyevents/rubyevents-ios/207b47dacd7a89c3bbdf95fd3cd2890624657473/RubyEvents/Assets.xcassets/LaunchScreen.imageset/LaunchScreen.png -------------------------------------------------------------------------------- /RubyEvents/Assets.xcassets/LaunchScreen.imageset/LaunchScreen@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubyevents/rubyevents-ios/207b47dacd7a89c3bbdf95fd3cd2890624657473/RubyEvents/Assets.xcassets/LaunchScreen.imageset/LaunchScreen@2x.png -------------------------------------------------------------------------------- /RubyEvents/Assets.xcassets/LaunchScreen.imageset/LaunchScreen@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rubyevents/rubyevents-ios/207b47dacd7a89c3bbdf95fd3cd2890624657473/RubyEvents/Assets.xcassets/LaunchScreen.imageset/LaunchScreen@3x.png -------------------------------------------------------------------------------- /RubyEvents/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /RubyEvents/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /RubyEvents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIUserInterfaceStyle 6 | Light 7 | UIApplicationSceneManifest 8 | 9 | UIApplicationSupportsMultipleScenes 10 | 11 | UISceneConfigurations 12 | 13 | UIWindowSceneSessionRoleApplication 14 | 15 | 16 | UISceneConfigurationName 17 | Default Configuration 18 | UISceneDelegateClassName 19 | $(PRODUCT_MODULE_NAME).SceneDelegate 20 | UISceneStoryboardFile 21 | Main 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /RubyEvents/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 4 | var window: UIWindow? 5 | 6 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 7 | App.instance.start(sceneDelegate: self) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /RubyEvents/api/APIService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIService.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 28.03.2025. 6 | // 7 | 8 | import Foundation 9 | 10 | enum NetworkError: Error { 11 | case invalidURL 12 | case invalidResponse 13 | case invalidData 14 | case requestFailed(Error) 15 | } 16 | 17 | class APIService { 18 | static let shared = APIService() 19 | 20 | private init() {} 21 | 22 | func fetchData(from endpoint: String, completion: @escaping (Result) -> Void) { 23 | guard let url = URL(string: endpoint) else { 24 | completion(.failure(.invalidURL)) 25 | return 26 | } 27 | 28 | let task = URLSession.shared.dataTask(with: url) { data, response, error in 29 | if let error = error { 30 | completion(.failure(.requestFailed(error))) 31 | return 32 | } 33 | 34 | guard let httpResponse = response as? HTTPURLResponse, 35 | (200...299).contains(httpResponse.statusCode) else { 36 | completion(.failure(.invalidResponse)) 37 | return 38 | } 39 | 40 | guard let data = data else { 41 | completion(.failure(.invalidData)) 42 | return 43 | } 44 | 45 | do { 46 | let decodedData = try JSONDecoder().decode(T.self, from: data) 47 | completion(.success(decodedData)) 48 | } catch { 49 | completion(.failure(.invalidData)) 50 | } 51 | } 52 | 53 | task.resume() 54 | } 55 | 56 | func fetchData(from endpoint: String) async throws -> T { 57 | guard let url = URL(string: endpoint) else { 58 | throw NetworkError.invalidURL 59 | } 60 | 61 | let (data, response) = try await URLSession.shared.data(from: url) 62 | 63 | guard let httpResponse = response as? HTTPURLResponse, 64 | (200...299).contains(httpResponse.statusCode) else { 65 | throw NetworkError.invalidResponse 66 | } 67 | 68 | do { 69 | return try JSONDecoder().decode(T.self, from: data) 70 | } catch { 71 | throw NetworkError.invalidData 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /RubyEvents/api/HomeViewResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventResponse.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 28.03.2025. 6 | // 7 | 8 | struct HomeList: Decodable { 9 | let name: String 10 | let items: [T] 11 | let url: String 12 | } 13 | 14 | struct HomeViewResponse: Decodable { 15 | let featured: [Event] 16 | let events: [HomeList] 17 | let talks: [HomeList] 18 | let speakers: [HomeList] 19 | } 20 | -------------------------------------------------------------------------------- /RubyEvents/bridge/ButtonComponent.swift: -------------------------------------------------------------------------------- 1 | import HotwireNative 2 | import UIKit 3 | 4 | final class ButtonComponent: BridgeComponent { 5 | override class var name: String { "button" } 6 | 7 | override func onReceive(message: Message) { 8 | guard let viewController else { return } 9 | 10 | addButton(via: message, to: viewController) 11 | } 12 | 13 | private var viewController: UIViewController? { 14 | delegate.destination as? UIViewController 15 | } 16 | 17 | private func addButton(via message: Message, to viewController: UIViewController) { 18 | guard let data: MessageData = message.data() else { return } 19 | 20 | let action = UIAction { [unowned self] _ in 21 | self.reply(to: "connect") 22 | } 23 | 24 | let item = UIBarButtonItem(title: data.title, primaryAction: action) 25 | viewController.navigationItem.rightBarButtonItem = item 26 | } 27 | } 28 | 29 | private extension ButtonComponent { 30 | struct MessageData: Decodable { 31 | let title: String 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /RubyEvents/bridge/LargeTitleComponent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LargeTitleComponent.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 24.01.2025. 6 | // 7 | 8 | import HotwireNative 9 | import UIKit 10 | 11 | final class LargeTitleComponent: BridgeComponent { 12 | override class var name: String { "title" } 13 | 14 | override func onReceive(message: Message) { 15 | guard let viewController else { return } 16 | 17 | addTitle(via: message, to: viewController) 18 | } 19 | 20 | private var viewController: UIViewController? { 21 | delegate.destination as? UIViewController 22 | } 23 | 24 | private func addTitle(via message: Message, to viewController: UIViewController) { 25 | guard let data: MessageData = message.data() else { return } 26 | 27 | viewController.navigationItem.title = data.title 28 | viewController.navigationController?.navigationBar.prefersLargeTitles = true 29 | 30 | self.reply(to: "connect") 31 | } 32 | } 33 | 34 | private extension LargeTitleComponent { 35 | struct MessageData: Decodable { 36 | let title: String 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /RubyEvents/components/avatar/Avatar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Avatar.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 25.01.2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Avatar: View { 11 | var name: String 12 | var url: String? 13 | 14 | init(speaker: Speaker) { 15 | self.name = speaker.name 16 | self.url = speaker.avatar_url 17 | } 18 | 19 | init(name: String, url: String?) { 20 | self.name = name 21 | self.url = url 22 | } 23 | 24 | var hasImage: Bool { 25 | (url != nil && !url!.isEmpty) 26 | } 27 | 28 | var body: some View { 29 | if hasImage { 30 | ImageAvatar(name: name, url: url!) 31 | } else { 32 | InitialsAvatar(name: name) 33 | } 34 | } 35 | } 36 | 37 | #Preview { 38 | VStack { 39 | Avatar(speaker: Speaker.withAvatar()) 40 | .frame(width: 100, height: 100) 41 | .padding(5) 42 | 43 | Avatar(speaker: Speaker.withNoAvatar()) 44 | .frame(width: 100, height: 100) 45 | .padding(5) 46 | 47 | Avatar(name: "Aaron Patterson", url: "https://cdn.bsky.app/img/avatar/plain/did:plc:3n6tlxabmocwe3nyl4b3rtjk/bafkreifsxp2tv45kucvjsnqo26qtbrbu2kgvimiykpynptjg6eqen4lhge@jpeg") 48 | .frame(width: 100, height: 100) 49 | .padding(5) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /RubyEvents/components/avatar/ImageAvatar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageAvatar.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 15.01.2025. 6 | // 7 | 8 | import CachedAsyncImage 9 | import SwiftUI 10 | 11 | struct ImageAvatar: View { 12 | var name: String 13 | var url: String 14 | 15 | init(speaker: Speaker) { 16 | self.name = speaker.name 17 | self.url = speaker.avatar_url ?? "" 18 | } 19 | 20 | init(name: String, url: String) { 21 | self.name = name 22 | self.url = url 23 | } 24 | 25 | var fallback: some View { 26 | InitialsAvatar(name: name) 27 | } 28 | 29 | var placeholder: ((String) -> any View)? { 30 | { _ in fallback } 31 | } 32 | 33 | var image: (CPImage) -> any View { 34 | { image in 35 | Image(uiImage: image) 36 | .resizable() 37 | .aspectRatio(contentMode: .fill) 38 | .clipShape(Circle()) 39 | } 40 | } 41 | 42 | var error: ((String, @escaping () -> Void) -> any View)? { 43 | { _, _ in 44 | fallback 45 | } 46 | } 47 | 48 | var body: some View { 49 | if url.isEmpty { 50 | fallback 51 | } else { 52 | CachedAsyncImage( 53 | url: url, 54 | placeholder: placeholder, 55 | image: image, 56 | error: error 57 | ) 58 | } 59 | } 60 | } 61 | 62 | #Preview { 63 | VStack { 64 | ImageAvatar(speaker: Speaker.withAvatar()) 65 | .frame(width: 100, height: 100) 66 | .padding(5) 67 | 68 | ImageAvatar(name: "Aaron Patterson", url: "https://cdn.bsky.app/img/avatar/plain/did:plc:3n6tlxabmocwe3nyl4b3rtjk/bafkreifsxp2tv45kucvjsnqo26qtbrbu2kgvimiykpynptjg6eqen4lhge@jpeg") 69 | .frame(width: 100, height: 100) 70 | .padding(5) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /RubyEvents/components/avatar/InitialsAvatar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InitialsAvatar.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 15.01.2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct InitialsAvatar: View { 11 | var initials: String 12 | var size: CGFloat = 50 13 | 14 | static func computedInitials(name: String) -> String { 15 | let parts = name.split(separator: " ") 16 | let startingLetters = parts.map { $0.prefix(1) }.joined() 17 | 18 | return String(startingLetters.prefix(3)).uppercased() 19 | } 20 | 21 | init(speaker: Speaker) { 22 | self.initials = Self.computedInitials(name: speaker.name) 23 | } 24 | 25 | init(name: String) { 26 | self.initials = Self.computedInitials(name: name) 27 | } 28 | 29 | init(initials: String) { 30 | self.initials = initials 31 | } 32 | 33 | var body: some View { 34 | GeometryReader { geometry in 35 | let width = geometry.size.width 36 | let height = geometry.size.height 37 | 38 | ZStack { 39 | Circle().fill(.brandRed) 40 | 41 | Text(initials) 42 | .foregroundColor(.white) 43 | .bold() 44 | .font(.system(size: min(width, height) * 0.4)) 45 | .lineLimit(1) 46 | .minimumScaleFactor(0.1) 47 | } 48 | } 49 | } 50 | } 51 | 52 | #Preview { 53 | VStack { 54 | InitialsAvatar(speaker: Speaker.withNoAvatar()).frame(width: 150, height: 150) 55 | InitialsAvatar(name: "Aaaron Patterson").frame(width: 100, height: 100) 56 | InitialsAvatar(initials: "AR").frame(width: 50, height: 50) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /RubyEvents/components/cards/EventCard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventCard.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 29.03.2025. 6 | // 7 | 8 | import SwiftUI 9 | import HotwireNative 10 | 11 | struct EventCard: View { 12 | let event: Event 13 | var navigator: Navigator? 14 | 15 | var body: some View { 16 | Button(action: { 17 | if (event.url != nil) { 18 | navigator?.route(event.url) 19 | } 20 | }) { 21 | VStack(alignment: .leading, spacing: 8) { 22 | ZStack(alignment: .bottomTrailing) { 23 | if (event.card_image_url != nil) { 24 | AsyncImage(url: URL(string: event.card_image_url!)) { image in 25 | image 26 | .resizable() 27 | .scaledToFill() 28 | .aspectRatio(16 / 9, contentMode: .fill) 29 | } placeholder: { 30 | Rectangle() 31 | .frame(maxWidth: .infinity, maxHeight: 160) 32 | .foregroundColor(Color(hex: "#EFEFEF")) 33 | } 34 | 35 | .frame(width: 200, height: 120) 36 | .border(.gray, width: 1) 37 | .clipShape(RoundedRectangle(cornerRadius: 12)) 38 | .overlay( 39 | RoundedRectangle(cornerRadius: 13) 40 | .stroke(Color(hex: "#EFEFEF"), lineWidth: 1) 41 | ) 42 | } else { 43 | Text("No Image") 44 | } 45 | 46 | Text(event.location) 47 | .font(.caption2) 48 | .bold() 49 | .foregroundColor(.white) 50 | .padding(.horizontal, 6) 51 | .padding(.vertical, 2) 52 | .background(Color.black.opacity(0.7)) 53 | .cornerRadius(4) 54 | .padding(8) 55 | } 56 | 57 | VStack(alignment: .leading, spacing: 4) { 58 | Text(event.name) 59 | .font(.caption) 60 | .lineLimit(1) 61 | .truncationMode(.tail) 62 | .fontWeight(.medium) 63 | .foregroundStyle(.black) 64 | 65 | Text(event.dateString()) 66 | .font(.caption2) 67 | .foregroundColor(.gray) 68 | .fontWeight(.regular) 69 | .truncationMode(.tail) 70 | .lineLimit(1) 71 | } 72 | } 73 | .frame(maxWidth: 200) 74 | .aspectRatio(16/9, contentMode: .fit) 75 | } 76 | } 77 | } 78 | 79 | #Preview { 80 | ForEach(Event.samples(), id: \.id) { event in 81 | EventCard(event: event).padding() 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /RubyEvents/components/cards/FeaturedCard.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import HotwireNative 3 | 4 | struct FeaturedCard: View { 5 | var event: Event 6 | var navigator: Navigator? 7 | 8 | var body: some View { 9 | GeometryReader { geometry in 10 | VStack(spacing: 0) { 11 | VStack() { 12 | Spacer().frame(height: geometry.size.height / 6) 13 | 14 | if event.featured_image_url != nil { 15 | AsyncImage(url: URL(string: event.featured_image_url!)) { image in 16 | image.resizable().background() { 17 | Color(hex: event.featured_background) 18 | }.aspectRatio(16 / 9, contentMode: .fit) 19 | } placeholder: { 20 | Color(hex: event.featured_background) 21 | } 22 | } else { 23 | Text("No Image") 24 | } 25 | 26 | TextOverlay(event: event, navigator: navigator) 27 | } 28 | .background(Color(hex: event.featured_background)) 29 | .frame(maxWidth: .infinity, maxHeight: .infinity) 30 | }.edgesIgnoringSafeArea(.all) 31 | } 32 | } 33 | } 34 | 35 | struct TextOverlay: View { 36 | var event: Event 37 | let navigator: Navigator? 38 | 39 | var background: LinearGradient { 40 | .linearGradient( 41 | Gradient(colors: [ 42 | Color(hex: event.featured_background), 43 | .black.opacity(0.2) 44 | ]), 45 | startPoint: .top, 46 | endPoint: .bottom 47 | ) 48 | } 49 | 50 | var body: some View { 51 | ZStack { 52 | background 53 | 54 | VStack(spacing: 12) { 55 | Text(event.name) 56 | .font(.title2) 57 | .bold() 58 | .foregroundColor(Color(hex: event.featured_color)) 59 | 60 | Text("\(event.location) • \(event.dateString())") 61 | .font(.caption) 62 | .foregroundColor(Color(hex: event.featured_color)) 63 | .opacity(0.6) 64 | .bold(true) 65 | 66 | Button(action: { 67 | navigator?.route(event.url) 68 | }) { 69 | Text("Explore Talks") 70 | .font(.caption) 71 | .padding(.horizontal, 24) 72 | .padding(.vertical, 12) 73 | .background(Color.white) 74 | .foregroundColor(.black) 75 | .clipShape(Capsule()) 76 | .bold() 77 | } 78 | .padding(.top, 12) 79 | .padding(.bottom, 12) 80 | } 81 | .padding() 82 | .multilineTextAlignment(.center) 83 | } 84 | } 85 | } 86 | 87 | #Preview { 88 | FeaturedCard(event: Event.blueRidgeRuby()) 89 | } 90 | 91 | #Preview { 92 | FeaturedCard(event: Event.railsConf2024()) 93 | } 94 | 95 | #Preview { 96 | FeaturedCard(event: Event.rubyConf2024()) 97 | } 98 | 99 | #Preview { 100 | FeaturedCard(event: Event.railsWorld2024()) 101 | } 102 | -------------------------------------------------------------------------------- /RubyEvents/components/cards/TalkCard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TalkCard.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 24.01.2025. 6 | // 7 | 8 | import SwiftUI 9 | import HotwireNative 10 | 11 | struct TalkCard: View { 12 | let talk: Talk 13 | var navigator: Navigator? 14 | 15 | var body: some View { 16 | Button(action: { 17 | if (talk.url != nil) { 18 | navigator?.route(talk.url!) 19 | } 20 | }) { 21 | VStack(alignment: .leading, spacing: 8) { 22 | ZStack(alignment: .bottomTrailing) { 23 | AsyncImage(url: talk.thumbnail_url) { image in 24 | image 25 | .resizable() 26 | .scaledToFill() 27 | .aspectRatio(16 / 9, contentMode: .fill) 28 | } placeholder: { 29 | Rectangle() 30 | .frame(maxWidth: .infinity, maxHeight: 160) 31 | .foregroundColor(Color(hex: "#EFEFEF")) 32 | } 33 | 34 | .frame(width: 200, height: 120) 35 | .border(.gray, width: 1) 36 | .clipShape(RoundedRectangle(cornerRadius: 12)) 37 | .overlay( 38 | RoundedRectangle(cornerRadius: 13) 39 | .stroke(Color(hex: "#EFEFEF"), lineWidth: 1) 40 | ) 41 | 42 | if (talk.duration_in_seconds != nil) { 43 | Text(talk.formatted_duration()) 44 | .font(.caption2) 45 | .bold() 46 | .foregroundColor(.white) 47 | .padding(.horizontal, 6) 48 | .padding(.vertical, 2) 49 | .background(Color.black.opacity(0.7)) 50 | .cornerRadius(4) 51 | .padding(8) 52 | } 53 | } 54 | 55 | VStack(alignment: .leading, spacing: 4) { 56 | Text(talk.title) 57 | .font(.caption) 58 | .lineLimit(1) 59 | .truncationMode(.tail) 60 | .fontWeight(.medium) 61 | .foregroundStyle(.black) 62 | 63 | Text("\(talk.speakers[0].name) • \(talk.event_name)") 64 | .font(.caption2) 65 | .foregroundColor(.gray) 66 | .fontWeight(.regular) 67 | .truncationMode(.tail) 68 | .lineLimit(1) 69 | } 70 | } 71 | .frame(maxWidth: 200) 72 | .aspectRatio(16/9, contentMode: .fit) 73 | } 74 | } 75 | } 76 | 77 | #Preview { 78 | ForEach(Talk.samples(), id: \.id) { talk in 79 | TalkCard(talk: talk).padding() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /RubyEvents/components/carousel/EventCarousel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventCarousel.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 29.03.2025. 6 | // 7 | 8 | import SwiftUI 9 | import HotwireNative 10 | 11 | struct EventCarousel: View { 12 | let title: String 13 | let events: [Event] 14 | let navigator: Navigator? 15 | let viewAllURL: URL? 16 | 17 | var body: some View { 18 | VStack(alignment: .leading, spacing: 12) { 19 | HStack { 20 | Text(title) 21 | .font(.headline) 22 | .bold() 23 | 24 | Spacer() 25 | 26 | Button(action: { if viewAllURL != nil { navigator?.route(viewAllURL!) } }) { 27 | Text("View All") 28 | .font(.subheadline) 29 | .foregroundColor(.blue) 30 | } 31 | } 32 | .padding(.horizontal, 16) 33 | 34 | ScrollView(.horizontal, showsIndicators: false) { 35 | HStack(spacing: 16) { 36 | ForEach(events) { event in 37 | EventCard( 38 | event: event, 39 | navigator: navigator 40 | ) 41 | } 42 | } 43 | .padding(.horizontal, 16) 44 | } 45 | }.padding(.bottom, 16) 46 | } 47 | } 48 | 49 | #Preview { 50 | EventCarousel( 51 | title: "Test", 52 | events: Event.samples() + Event.samples(), 53 | navigator: nil, 54 | viewAllURL: nil 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /RubyEvents/components/carousel/FeaturedCarousel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EventCarousel.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 24.01.2025. 6 | // 7 | 8 | import SwiftUI 9 | import HotwireNative 10 | 11 | struct FeaturedCarousel: View { 12 | var events: [Event] 13 | var navigator: Navigator? 14 | 15 | var body: some View { 16 | TabView { 17 | ForEach(events) { event in 18 | Button(action: { 19 | navigator?.route(event.url) 20 | }) { 21 | FeaturedCard( 22 | event: event, 23 | navigator: navigator 24 | ) 25 | } 26 | } 27 | } 28 | .tabViewStyle( 29 | PageTabViewStyle(indexDisplayMode: .always) 30 | ) 31 | .animation(.interactiveSpring, value: events) 32 | .edgesIgnoringSafeArea(.all) 33 | } 34 | } 35 | 36 | #Preview { 37 | FeaturedCarousel(events: [ 38 | Event.blueRidgeRuby(), 39 | Event.railsConf2024(), 40 | Event.railsWorld2024(), 41 | Event.rubyConf2024() 42 | ]) 43 | } 44 | -------------------------------------------------------------------------------- /RubyEvents/components/carousel/SpeakerCarousel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserCarousel.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 27.03.2025. 6 | // 7 | 8 | import SwiftUI 9 | import HotwireNative 10 | 11 | struct SpeakerCarousel: View { 12 | let title: String 13 | let speakers: [Speaker] 14 | let navigator: Navigator? 15 | let viewAllURL: URL? 16 | 17 | func navigateToProfile(speaker: Speaker) { 18 | let proposal = VisitProposal( 19 | url: speaker.profile_url, 20 | options: VisitOptions(), 21 | properties: ["tabs": false] 22 | ) 23 | 24 | navigator?.route(proposal) 25 | } 26 | 27 | 28 | var body: some View { 29 | VStack(alignment: .leading, spacing: 12) { 30 | HStack { 31 | Text(title) 32 | .font(.headline) 33 | .bold() 34 | 35 | Spacer() 36 | 37 | Button(action: { if viewAllURL != nil { navigator?.route(viewAllURL!) } }) { 38 | Text("View All") 39 | .font(.subheadline) 40 | .foregroundColor(.blue) 41 | } 42 | } 43 | .padding(.horizontal, 16) 44 | 45 | ScrollView(.horizontal, showsIndicators: false) { 46 | HStack(spacing: 8) { 47 | ForEach(speakers) { speaker in 48 | Button(action: { navigateToProfile(speaker: speaker) }) { 49 | VStack(alignment: .center) { 50 | Avatar( 51 | speaker: speaker 52 | ).frame(width: 100, height: 100) 53 | .padding(5) 54 | 55 | Text(speaker.name) 56 | .font(.subheadline) 57 | .foregroundStyle(.black) 58 | .lineLimit(1) 59 | .truncationMode(.tail) 60 | .frame(width: 120, alignment: .center) 61 | } 62 | .frame(width: 120) 63 | } 64 | } 65 | } 66 | .padding(.horizontal, 16) 67 | } 68 | }.padding(.bottom, 16) 69 | } 70 | } 71 | 72 | #Preview { 73 | SpeakerCarousel( 74 | title: "Active Speakers", 75 | speakers: Speaker.samples() + Speaker.samples(), 76 | navigator: nil, 77 | viewAllURL: nil 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /RubyEvents/components/carousel/TalkCarousel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TalkCarousel.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 24.01.2025. 6 | // 7 | 8 | import SwiftUI 9 | import HotwireNative 10 | 11 | struct TalkCarousel: View { 12 | let title: String 13 | let talks: [Talk] 14 | let navigator: Navigator? 15 | let viewAllURL: URL? 16 | 17 | var body: some View { 18 | VStack(alignment: .leading, spacing: 12) { 19 | HStack { 20 | Text(title) 21 | .font(.headline) 22 | .bold() 23 | 24 | Spacer() 25 | 26 | Button(action: { if viewAllURL != nil { navigator?.route(viewAllURL!) } }) { 27 | Text("View All") 28 | .font(.subheadline) 29 | .foregroundColor(.blue) 30 | } 31 | } 32 | .padding(.horizontal, 16) 33 | 34 | ScrollView(.horizontal, showsIndicators: false) { 35 | HStack(spacing: 16) { 36 | ForEach(talks) { talk in 37 | TalkCard( 38 | talk: talk, 39 | navigator: navigator 40 | ) 41 | } 42 | } 43 | .padding(.horizontal, 16) 44 | } 45 | }.padding(.bottom, 16) 46 | } 47 | } 48 | 49 | #Preview { 50 | TalkCarousel( 51 | title: "Test", 52 | talks: Talk.samples(), 53 | navigator: nil, 54 | viewAllURL: nil 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /RubyEvents/controllers/PageViewController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | struct PageViewController: UIViewControllerRepresentable { 5 | var pages: [Page] 6 | 7 | func makeUIViewController(context: Context) -> UIPageViewController { 8 | let pageViewController = UIPageViewController( 9 | transitionStyle: .scroll, 10 | navigationOrientation: .horizontal) 11 | 12 | 13 | return pageViewController 14 | } 15 | 16 | 17 | func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) { 18 | pageViewController.setViewControllers( 19 | [UIHostingController(rootView: pages[0])], direction: .forward, animated: true) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /RubyEvents/controllers/TabBarController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarController.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 11.01.2025. 6 | // 7 | 8 | import HotwireNative 9 | import UIKit 10 | 11 | struct TabBarConfigurationItem { 12 | let title: String 13 | let icon: String 14 | let url: URL 15 | let position: Int 16 | } 17 | 18 | struct TabBarConfiguration { 19 | let items: [TabBarConfigurationItem] 20 | } 21 | 22 | class TabBarController: UITabBarController { 23 | var app: App 24 | var configuration: TabBarConfiguration? 25 | var navigators: [Navigator] = [] 26 | var isSetup: Bool = false 27 | 28 | init(app: App) { 29 | self.app = app 30 | super.init(nibName: nil, bundle: nil) 31 | } 32 | 33 | func fixedTabBarItemFrom(item: TabBarConfigurationItem) -> FixedTabBarItem { 34 | FixedTabBarItem( 35 | title: item.title, 36 | image: UIImage(systemName: item.icon), 37 | tag: item.position 38 | ) 39 | } 40 | 41 | func reloadAllTabs() { 42 | self.navigators.forEach { navigator in 43 | reloadTab(navigator: navigator) 44 | } 45 | } 46 | 47 | func reloadTab(title: String) { 48 | if let navigator = app.navigatorFor(title: title) { 49 | reloadTab(navigator: navigator) 50 | } 51 | } 52 | 53 | func reloadTab(navigator: Navigator) { 54 | navigator.rootViewController.popToRootViewController(animated: false) 55 | navigator.activeWebView.reload() 56 | } 57 | 58 | func navigatorFor(title: String) -> Navigator? { 59 | let index = configuration?.items.firstIndex { $0.self.title == title } 60 | 61 | guard let index else { return nil } 62 | 63 | return navigators[index] 64 | } 65 | 66 | var currentNavigator: Navigator? { 67 | navigatorFor(title: currentTabTitle ?? "") 68 | } 69 | 70 | var currentTabTitle: String? { 71 | self.tabBar.selectedItem?.title 72 | } 73 | 74 | func hideNavigationBarFor(title: String) { 75 | let navigator = self.navigatorFor(title: title) 76 | navigator?.rootViewController.navigationBar.isHidden = true 77 | } 78 | 79 | func showNavigationBarFor(title: String) { 80 | let navigator = self.navigatorFor(title: title) 81 | navigator?.rootViewController.navigationBar.isHidden = false 82 | } 83 | 84 | func hideTabBar() { 85 | self.tabBar.isHidden = true 86 | } 87 | 88 | func showTabBar() { 89 | self.tabBar.isHidden = false 90 | } 91 | 92 | func setupTabs() { 93 | setup( 94 | configuration: TabBarConfiguration( 95 | items: [ 96 | TabBarConfigurationItem( 97 | title: "Home", 98 | icon: "house", 99 | url: Router.instance.home_url(), 100 | position: 0 101 | ), 102 | TabBarConfigurationItem( 103 | title: "Events", 104 | icon: "calendar", 105 | url: Router.instance.events_url(), 106 | position: 1 107 | ), 108 | TabBarConfigurationItem( 109 | title: "Talks", 110 | icon: "music.mic", 111 | url: Router.instance.talks_url(), 112 | position: 2 113 | ), 114 | TabBarConfigurationItem( 115 | title: "Speakers", 116 | icon: "person.fill", 117 | url: Router.instance.speakers_url(), 118 | position: 3 119 | ) 120 | ] 121 | ) 122 | ) 123 | } 124 | 125 | func setup(configuration: TabBarConfiguration) { 126 | guard !isSetup else { return } 127 | 128 | self.configuration = configuration 129 | 130 | var tabBarItems: [FixedTabBarItem] = [] 131 | 132 | configuration.items.forEach { item in 133 | let navigator = Navigator(delegate: app) 134 | self.navigators.append(navigator) 135 | 136 | navigator.route(item.url) 137 | 138 | tabBarItems.append(fixedTabBarItemFrom(item: item)) 139 | } 140 | 141 | viewControllers = navigators.map(\.rootViewController) 142 | 143 | tabBarItems.enumerated().forEach { index, item in 144 | if let viewController = self.viewControllers?[index] { 145 | // viewController.tabBarController?.tabBar.tintColor = .white 146 | // viewController.tabBarController?.tabBar.barTintColor = .black 147 | viewController.tabBarItem = item 148 | } 149 | } 150 | 151 | // NavBar styling 152 | let navigationBarAppearance = UINavigationBarAppearance() 153 | navigationBarAppearance.configureWithDefaultBackground() 154 | UINavigationBar.appearance().standardAppearance = navigationBarAppearance 155 | UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance 156 | UINavigationBar.appearance().isOpaque = false 157 | UINavigationBar.appearance().isTranslucent = true 158 | navigationBarAppearance.titleTextAttributes = [.foregroundColor: UIColor.black] 159 | 160 | // TabBar styling 161 | let tabBarAppearance = UITabBarAppearance() 162 | tabBarAppearance.configureWithDefaultBackground() 163 | tabBarAppearance.backgroundColor = UIColor.white 164 | UITabBar.appearance().standardAppearance = tabBarAppearance 165 | UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance 166 | UITabBar.appearance().isOpaque = true 167 | UITabBar.appearance().isTranslucent = false 168 | UITabBar.appearance().tintColor = .red 169 | UITabBar.appearance().unselectedItemTintColor = .black 170 | 171 | self.isSetup = true 172 | } 173 | 174 | func reset() { 175 | self.isSetup = false 176 | self.configuration = nil 177 | self.navigators = [] 178 | self.viewControllers = nil 179 | self.selectedIndex = 0 180 | } 181 | 182 | required init?(coder _: NSCoder) { 183 | fatalError("init(coder:) has not been implemented") 184 | } 185 | 186 | override func viewDidLoad() { 187 | super.viewDidLoad() 188 | 189 | // self.tabBar.backgroundColor = .white 190 | // self.tabBar.barStyle = .default 191 | // self.tabBar.isTranslucent = false 192 | // self.tabBar.isOpaque = false 193 | // self.tabBar.barTintColor = .white 194 | // self.tabBar.tintColor = .gray 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /RubyEvents/enums/Environment.swift: -------------------------------------------------------------------------------- 1 | enum Environment { 2 | case development 3 | case staging 4 | case production 5 | } 6 | -------------------------------------------------------------------------------- /RubyEvents/lib/Color.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Color { 4 | init(hex: String) { 5 | var string = hex 6 | 7 | if string.hasPrefix("#") { 8 | string.removeFirst() 9 | } 10 | 11 | if string.count == 3 { 12 | string = String(repeating: string[string.startIndex], count: 2) 13 | + String(repeating: string[string.index(string.startIndex, offsetBy: 1)], count: 2) 14 | + String(repeating: string[string.index(string.startIndex, offsetBy: 2)], count: 2) 15 | } else if !string.count.isMultiple(of: 2) || string.count > 8 { 16 | self.init(.red) 17 | } 18 | 19 | guard let color = UInt64(string, radix: 16) else { 20 | self.init(.yellow) 21 | return 22 | } 23 | 24 | if string.count == 2 { 25 | let gray = Double(Int(color) & 0xFF) / 255 26 | 27 | self.init(.sRGB, red: gray, green: gray, blue: gray, opacity: 1) 28 | 29 | } else if string.count == 4 { 30 | let gray = Double(Int(color >> 8) & 0x00FF) / 255 31 | let alpha = Double(Int(color) & 0x00FF) / 255 32 | 33 | self.init(.sRGB, red: gray, green: gray, blue: gray, opacity: alpha) 34 | 35 | } else if string.count == 6 { 36 | let red = Double(Int(color >> 16) & 0x0000FF) / 255 37 | let green = Double(Int(color >> 8) & 0x0000FF) / 255 38 | let blue = Double(Int(color) & 0x0000FF) / 255 39 | 40 | self.init(.sRGB, red: red, green: green, blue: blue, opacity: 1) 41 | 42 | } else if string.count == 8 { 43 | let red = Double(Int(color >> 24) & 0x000000FF) / 255 44 | let green = Double(Int(color >> 16) & 0x000000FF) / 255 45 | let blue = Double(Int(color >> 8) & 0x000000FF) / 255 46 | let alpha = Double(Int(color) & 0x000000FF) / 255 47 | 48 | self.init(.sRGB, red: red, green: green, blue: blue, opacity: alpha) 49 | 50 | } else { 51 | self.init(.blue) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /RubyEvents/lib/Endpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Endpoint { 4 | static let development = URL(string: "http://172.20.4.17:3000")! 5 | static let staging = URL(string: "https://www.rubyevents.org")! 6 | static let production = URL(string: "https://www.rubyevents.org")! 7 | 8 | static func url(environment: Environment = .development) -> URL { 9 | switch environment { 10 | case .development: return development 11 | case .staging: return staging 12 | case .production: return production 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /RubyEvents/lib/FixedTabBarItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FixedTabBarItem.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 11.01.2025. 6 | // 7 | 8 | import UIKit 9 | 10 | class FixedTabBarItem: UITabBarItem { 11 | override var title: String? { 12 | didSet { 13 | // Prevent the title from changing after initial setup 14 | if oldValue != nil { 15 | super.title = oldValue 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /RubyEvents/lib/Router.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class Router { 4 | static let instance = Router() 5 | 6 | func root_url() -> URL { 7 | Endpoint.url(environment: App.instance.environment) 8 | } 9 | 10 | func path_configuration_url() -> URL { 11 | root_url().appendingPathComponent("/hotwire/native/v1/ios/path_configuration.json") 12 | } 13 | 14 | func home_url() -> URL { 15 | root_url().appendingPathComponent("/home") 16 | } 17 | 18 | func home_json_url() -> URL { 19 | root_url().appendingPathComponent("/hotwire/native/v1/home.json") 20 | } 21 | 22 | func talks_url() -> URL { 23 | return root_url().appending(path: "/talks") 24 | } 25 | 26 | func topics_url() -> URL { 27 | return root_url().appending(path: "/topics") 28 | } 29 | 30 | func events_url() -> URL { 31 | return root_url().appending(path: "/events") 32 | } 33 | 34 | func speakers_url() -> URL { 35 | return root_url().appending(path: "/speakers") 36 | } 37 | 38 | func speaker_url(slug: String) -> URL { 39 | root_url().appendingPathComponent("/speakers/\(slug)/") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /RubyEvents/models/Event.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct Event: Hashable, Identifiable, Decodable { 5 | var id: Int64 6 | var name: String 7 | var slug: String 8 | var location: String 9 | var start_date: String 10 | var end_date: String 11 | var card_image_url: String? 12 | var featured_image_url: String? 13 | var featured_background: String 14 | var featured_color: String 15 | var url: URL 16 | 17 | var featureImage: Image? { 18 | featured_image_url != nil ? Image(featured_image_url!) : nil 19 | } 20 | 21 | private enum CodingKeys: String, CodingKey { 22 | case id 23 | case name 24 | case slug 25 | case location 26 | case start_date 27 | case end_date 28 | case card_image_url 29 | case featured_image_url 30 | case featured_background 31 | case featured_color 32 | case url 33 | } 34 | 35 | init( 36 | id: Int64, 37 | name: String, 38 | slug: String, 39 | location: String, 40 | start_date: String, 41 | end_date: String, 42 | card_image_url: String? = nil, 43 | featured_image_url: String? = nil, 44 | featured_background: String = "#FFFFFF", 45 | featured_color: String = "#000000", 46 | url: URL 47 | ) { 48 | self.id = id 49 | self.name = name 50 | self.slug = slug 51 | self.location = location 52 | self.start_date = start_date 53 | self.end_date = end_date 54 | self.card_image_url = card_image_url 55 | self.featured_image_url = featured_image_url 56 | self.featured_background = featured_background 57 | self.featured_color = featured_color 58 | self.url = url 59 | } 60 | 61 | init(from decoder: Decoder) throws { 62 | let container = try decoder.container(keyedBy: CodingKeys.self) 63 | 64 | id = try container.decode(Int64.self, forKey: .id) 65 | name = try container.decode(String.self, forKey: .name) 66 | slug = try container.decode(String.self, forKey: .slug) 67 | 68 | location = try container.decodeIfPresent(String.self, forKey: .location) ?? "TBD" 69 | start_date = try container.decodeIfPresent(String.self, forKey: .start_date) ?? "" 70 | end_date = try container.decodeIfPresent(String.self, forKey: .end_date) ?? "" 71 | card_image_url = try container.decodeIfPresent(String.self, forKey: .card_image_url) 72 | featured_image_url = try container.decodeIfPresent(String.self, forKey: .featured_image_url) 73 | featured_background = try container.decodeIfPresent(String.self, forKey: .featured_background) ?? "#FFFFFF" 74 | featured_color = try container.decodeIfPresent(String.self, forKey: .featured_color) ?? "#000000" 75 | 76 | do { 77 | url = try container.decode(URL.self, forKey: .url) 78 | } catch { 79 | url = Router.instance.root_url().appending(path: "/events/\(id)") 80 | } 81 | } 82 | 83 | static var keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy { 84 | return .useDefaultKeys 85 | } 86 | 87 | func formatter() -> DateFormatter { 88 | let formatter = DateFormatter() 89 | formatter.dateFormat = "yyyy-MM-dd" 90 | 91 | return formatter 92 | } 93 | 94 | func startDate() -> Date? { 95 | return formatter().date(from: start_date) 96 | } 97 | 98 | func endDate() -> Date? { 99 | return formatter().date(from: end_date) 100 | } 101 | 102 | func dateString() -> String { 103 | let startDate = startDate() 104 | let endDate = endDate() 105 | 106 | guard let startDate = startDate else { 107 | return "Date TBD" 108 | } 109 | 110 | let formatter = DateFormatter() 111 | formatter.dateFormat = "MMMM dd, yyyy" 112 | 113 | if let endDate = endDate { 114 | if Calendar.current.isDate(startDate, inSameDayAs: endDate) { 115 | return formatter.string(from: startDate) 116 | } else { 117 | return formatter.string(from: startDate) + " - " + formatter.string(from: endDate) 118 | } 119 | } 120 | 121 | return formatter.string(from: startDate) 122 | } 123 | 124 | static func samples() -> [Event] { 125 | return [ 126 | blueRidgeRuby(), 127 | rubyConf2024(), 128 | railsConf2024(), 129 | railsWorld2024() 130 | ] 131 | } 132 | 133 | static func blueRidgeRuby() -> Event { 134 | return Event( 135 | id: 1, 136 | name: "Blue Ridge Ruby 2024", 137 | slug: "blue-ridge-ruby-2024", 138 | location: "Asheville, NC", 139 | start_date: "2024-05-30", 140 | end_date: "2024-05-31", 141 | featured_image_url: "https://www.rubyvideo.dev/assets/events/blue-ridge-ruby/blue-ridge-ruby-2024/featured-d3f11d02.webp", 142 | featured_background: "#E1EFFA", 143 | featured_color: "#0C2866", 144 | url: Router.instance.root_url().appending(path: "/events/blue-ridge-ruby-2024") 145 | ) 146 | } 147 | 148 | static func rubyConf2024() -> Event { 149 | return Event( 150 | id: 2, 151 | name: "RubyConf 2024", 152 | slug: "rubyconf-2024", 153 | location: "Chicago, IL", 154 | start_date: "2024-11-13", 155 | end_date: "2024-11-15", 156 | featured_image_url: "https://www.rubyvideo.dev/assets/events/rubyconf/rubyconf-2024/featured-a6512cb9.webp", 157 | featured_background: "#05061C", 158 | featured_color: "#FFFFFF", 159 | url: Router.instance.root_url().appending(path: "/events/rubyconf-2024") 160 | ) 161 | } 162 | 163 | static func railsConf2024() -> Event { 164 | return Event( 165 | id: 3, 166 | name: "RailsConf 2024", 167 | slug: "rails-conf-2024", 168 | location: "Detroit, MI", 169 | start_date: "2024-05-07", 170 | end_date: "2024-05-09", 171 | featured_image_url: "https://www.rubyvideo.dev/assets/events/railsconf/railsconf-2024/featured-977c1ad4.webp", 172 | featured_background: "#231F20", 173 | featured_color: "#FFFFFF", 174 | url: Router.instance.root_url().appending(path: "/events/railsconf-2024") 175 | ) 176 | } 177 | 178 | static func railsWorld2024() -> Event { 179 | return Event( 180 | id: 4, 181 | name: "Rails World 2024", 182 | slug: "rails-world-2024", 183 | location: "Toronto, Canada", 184 | start_date: "2024-09-23", 185 | end_date: "2024-09-24", 186 | featured_image_url: "https://www.rubyvideo.dev/assets/events/rails-world/rails-world-2024/featured-01bba711.webp", 187 | featured_background: "#4E2A73", 188 | featured_color: "#FFFFFF", 189 | url: Router.instance.root_url().appending(path: "/events/rails-world-2024") 190 | ) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /RubyEvents/models/Speaker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Speaker.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 15.01.2025. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Speaker: Identifiable, Decodable { 11 | var id: Int64 12 | var name: String 13 | var slug: String 14 | var avatar_url: String? 15 | 16 | var profile_url: URL { 17 | Router.instance.speaker_url(slug: self.slug) 18 | } 19 | 20 | static func withAvatar() -> Self { 21 | Self( 22 | id: 1, 23 | name: "Aaron Patterson", 24 | slug: "aaron-patterson", 25 | avatar_url: "https://avatars.githubusercontent.com/u/3124?v=4" 26 | ) 27 | } 28 | 29 | static func withNoAvatar() -> Self { 30 | Self( 31 | id: 1, 32 | name: "Aaron Patterson", 33 | slug: "aaron-patterson", 34 | avatar_url: nil 35 | ) 36 | } 37 | 38 | static func samples() -> [Self] { 39 | [ 40 | withAvatar(), 41 | withNoAvatar() 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /RubyEvents/models/Talk.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Talk.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 24.01.2025. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Talk: Identifiable, Decodable { 11 | let id: Int64 12 | let title: String 13 | let speakers: [Speaker] 14 | let duration_in_seconds: Int32? 15 | let event_name: String 16 | let url: URL? 17 | let thumbnail_url: URL? 18 | let slug: String 19 | 20 | enum CodingKeys: String, CodingKey { 21 | case id 22 | case title 23 | case speakers 24 | case duration_in_seconds 25 | case event_name 26 | case url 27 | case thumbnail_url 28 | case slug 29 | } 30 | 31 | init(from decoder: Decoder) throws { 32 | let container = try decoder.container(keyedBy: CodingKeys.self) 33 | 34 | id = try container.decode(Int64.self, forKey: .id) 35 | title = try container.decode(String.self, forKey: .title) 36 | speakers = try container.decode([Speaker].self, forKey: .speakers) 37 | duration_in_seconds = try container.decodeIfPresent(Int32.self, forKey: .duration_in_seconds) 38 | event_name = try container.decode(String.self, forKey: .event_name) 39 | 40 | if let urlString = try container.decodeIfPresent(String.self, forKey: .url) { 41 | url = URL(string: urlString) 42 | } else { 43 | url = nil 44 | } 45 | 46 | if let thumbnailString = try container.decodeIfPresent(String.self, forKey: .thumbnail_url) { 47 | thumbnail_url = URL(string: thumbnailString) 48 | } else { 49 | thumbnail_url = nil 50 | } 51 | 52 | slug = try container.decode(String.self, forKey: .slug) 53 | } 54 | 55 | init(id: Int64, title: String, speakers: [Speaker], duration_in_seconds: Int32?, event_name: String, url: URL?, thumbnail_url: URL?, slug: String) { 56 | self.id = id 57 | self.title = title 58 | self.speakers = speakers 59 | self.duration_in_seconds = duration_in_seconds 60 | self.event_name = event_name 61 | self.url = url 62 | self.thumbnail_url = thumbnail_url 63 | self.slug = slug 64 | } 65 | 66 | func formatted_duration() -> String { 67 | guard let seconds = duration_in_seconds else { 68 | return "" 69 | } 70 | 71 | let totalSeconds = Int(seconds) 72 | let hours = totalSeconds / 3600 73 | let minutes = (totalSeconds % 3600) / 60 74 | let remainingSeconds = totalSeconds % 60 75 | 76 | if hours > 0 { 77 | return String(format: "%d:%02d:%02d", hours, minutes, remainingSeconds) 78 | } else { 79 | return String(format: "%d:%02d", minutes, remainingSeconds) 80 | } 81 | } 82 | 83 | static func samples() -> [Talk] { 84 | return [ 85 | Talk( 86 | id: 1, 87 | title: "The present and future of SQLite on Rails", 88 | speakers: [ 89 | Speaker(id: 101, name: "Stephen Margheim", slug: "stephen-margheim") 90 | ], 91 | duration_in_seconds: 1800, // 30 minutes 92 | event_name: "Ruby Türkiye Meetup January 2025", 93 | url: Router.instance.root_url().appending(path: "/talks/the-present-and-future-of-sqlite-on-rails"), 94 | thumbnail_url: URL(string: "https://media.kommunity.com/communities/ruby-turkiye/events/the-present-and-future-of-sqlite-on-rails-79926ab2/70489/stephen.png"), 95 | slug: "the-present-and-future-of-sqlite-on-rails" 96 | ), 97 | 98 | Talk( 99 | id: 2, 100 | title: "Express Your Ideas by Writing Your Own Gems", 101 | speakers: [ 102 | Speaker(id: 102, name: "Kasper Timm Hansen", slug: "kasper-timm-hansen") 103 | ], 104 | duration_in_seconds: 2400, // 40 minutes 105 | event_name: "Ruby Banitsa Conf 2024", 106 | url: Router.instance.root_url().appending(path: "/talks/express-your-ideas-by-writing-your-own-gems"), 107 | thumbnail_url: URL(string: "https://pbs.twimg.com/media/GeMpS7LWsAAfR-6?format=jpg"), 108 | slug: "express-your-ideas-by-writing-your-own-gems" 109 | ), 110 | 111 | Talk( 112 | id: 3, 113 | title: "SF Bay Area Ruby Meetup - December 2024", 114 | speakers: [ 115 | Speaker(id: 103, name: "Irina Nazarova", slug: "irina-nazarova"), 116 | Speaker(id: 104, name: "Tillman Elser", slug: "tillman-elser"), 117 | Speaker(id: 105, name: "Justin Bowen", slug: "justin-bowen") 118 | ], 119 | duration_in_seconds: 5400, // 90 minutes 120 | event_name: "SF Bay Area Ruby Meetup - December 2024", 121 | url: Router.instance.root_url().appending(path: "/talks/sf-bay-area-ruby-meetup-december-2024"), 122 | thumbnail_url: URL(string: "https://i.ytimg.com/vi/NU7ld8ERUFY/sddefault.jpg"), 123 | slug: "sf-bay-area-ruby-meetup-december-2024" 124 | ), 125 | 126 | Talk( 127 | id: 4, 128 | title: "Panel Discussion", 129 | speakers: [ 130 | Speaker(id: 106, name: "Gautam Rege", slug: "gautam-rege"), 131 | Speaker(id: 107, name: "Dutta Deshmukh", slug: "dutta-deshmukh"), 132 | Speaker(id: 108, name: "Surbhi Gupta", slug: "surbhi-gupta") 133 | ], 134 | duration_in_seconds: 3600, // 60 minutes 135 | event_name: "RubyConf India 2024", 136 | url: Router.instance.root_url().appending(path: "/talks/panel-discussion-rubyconf-india-2024"), 137 | thumbnail_url: URL(string: "https://i.ytimg.com/vi/VIS42lVAwfw/sddefault.jpg"), 138 | slug: "panel-discussion-rubyconf-india-2024" 139 | ), 140 | 141 | Talk( 142 | id: 5, 143 | title: "Compose Software Like Nature Would", 144 | speakers: [ 145 | Speaker(id: 109, name: "Ahmed Omran", slug: "ahmed-omran") 146 | ], 147 | duration_in_seconds: 1800, // 30 minutes 148 | event_name: "RubyConf 2024", 149 | url: Router.instance.root_url().appending(path: "/talks/compose-software-like-nature-would"), 150 | thumbnail_url: URL(string: "https://i.ytimg.com/vi/bVBAvCm2mCs/sddefault.jpg"), 151 | slug: "compose-software-like-nature-would" 152 | ), 153 | 154 | Talk( 155 | id: 6, 156 | title: "Code Review Automation: Getting Rid of \"You Forgot To...\" Comments", 157 | speakers: [ 158 | Speaker(id: 110, name: "Egor Iskrenkov", slug: "egor-iskrenkov") 159 | ], 160 | duration_in_seconds: 2700, // 45 minutes 161 | event_name: "Madrid.rb November 2024", 162 | url: Router.instance.root_url().appending(path: "/talks/code-review-automation-getting-rid-of-you-forgot-to-comments"), 163 | thumbnail_url: URL(string: "https://i.ytimg.com/vi/NDlpphmB1VU/sddefault.jpg"), 164 | slug: "code-review-automation-getting-rid-of-you-forgot-to-comments" 165 | ), 166 | 167 | Talk( 168 | id: 7, 169 | title: "Hotwire Native: Turn Your Rails App into a Mobile App", 170 | speakers: [ 171 | Speaker(id: 111, name: "Yaroslav Shmarov", slug: "yaroslav-shmarov") 172 | ], 173 | duration_in_seconds: 2400, // 40 minutes 174 | event_name: "Paris.rb Meetup", 175 | url: Router.instance.root_url().appending(path: "/talks/hotwire-native-turn-your-rails-app-into-a-mobile-app"), 176 | thumbnail_url: URL(string: "https://i.ytimg.com/vi/25vqzypzTkQ/sddefault.jpg"), 177 | slug: "hotwire-native-turn-your-rails-app-into-a-mobile-app" 178 | ), 179 | 180 | Talk( 181 | id: 8, 182 | title: "Parsing with Prism in Sorbet", 183 | speakers: [ 184 | Speaker(id: 112, name: "Emily Samp", slug: "emily-samp") 185 | ], 186 | duration_in_seconds: 1800, // 30 minutes 187 | event_name: "WNB.rb Meetup October 2024", 188 | url: Router.instance.root_url().appending(path: "/talks/parsing-with-prism-in-sorbet"), 189 | thumbnail_url: URL(string: "https://i.ytimg.com/vi/rnGMDz-2YVE/sddefault.jpg"), 190 | slug: "parsing-with-prism-in-sorbet" 191 | ), 192 | 193 | Talk( 194 | id: 9, 195 | title: "omakaseしないためのrubocop.yml のつくりかた", 196 | speakers: [ 197 | Speaker(id: 113, name: "Shu Oogawara", slug: "shu-oogawara") 198 | ], 199 | duration_in_seconds: 1500, // 25 minutes 200 | event_name: "Kaigi on Rails 2024", 201 | url: Router.instance.root_url().appending(path: "/talks/omakase-rubocop-yml"), 202 | thumbnail_url: URL(string: "https://i.ytimg.com/vi/ZFpL3-RdUys/sddefault.jpg"), 203 | slug: "omakase-rubocop-yml" 204 | ), 205 | 206 | Talk( 207 | id: 10, 208 | title: "Game Show and Closing Remarks", 209 | speakers: [ 210 | Speaker(id: 114, name: "Spike Ilacqua", slug: "spike-ilacqua"), 211 | Speaker(id: 115, name: "Bekki Freeman", slug: "bekki-freeman") 212 | ], 213 | duration_in_seconds: 1800, // 30 minutes 214 | event_name: "Rocky Mountain Ruby 2024", 215 | url: Router.instance.root_url().appending(path: "/talks/game-show-and-closing-remarks"), 216 | thumbnail_url: URL(string: "https://i.ytimg.com/vi/YK-fnF-CNxc/sddefault.jpg"), 217 | slug: "game-show-and-closing-remarks" 218 | ), 219 | ] 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /RubyEvents/path-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": {}, 3 | "rules": [ 4 | { 5 | "patterns": [ 6 | "^$", 7 | "^/$", 8 | "^/home$" 9 | ], 10 | "properties": { 11 | "view_controller": "home" 12 | } 13 | }, 14 | { 15 | "patterns": [ 16 | "/player$" 17 | ], 18 | "properties": { 19 | "view_controller": "player" 20 | } 21 | }, 22 | { 23 | "patterns": [ 24 | "/new$", 25 | "/edit$" 26 | ], 27 | "properties": { 28 | "context": "modal" 29 | } 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /RubyEvents/views/HomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeView.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 24.01.2025. 6 | // 7 | 8 | import SwiftUI 9 | import HotwireNative 10 | 11 | struct HomeView: View { 12 | @State private var featured: [Event] = [] 13 | @State private var talks: [HomeList] = [] 14 | @State private var events: [HomeList] = [] 15 | @State private var speakers: [HomeList] = [] 16 | 17 | @State private var isLoading: Bool = true 18 | @State private var errorMessage: String? = nil 19 | @State private var hasLoadedInitialData: Bool = false 20 | 21 | var navigator: Navigator? 22 | 23 | var body: some View { 24 | GeometryReader { geometry in 25 | if isLoading { 26 | HomeViewSkeleton().frame(maxWidth: .infinity, maxHeight: .infinity) 27 | } else if let error = errorMessage { 28 | VStack { 29 | Text("Error loading data") 30 | .font(.headline) 31 | Text(error) 32 | .font(.subheadline) 33 | .foregroundColor(.red) 34 | Button("Retry") { 35 | fetchData() 36 | } 37 | .padding() 38 | .background(Color.blue) 39 | .foregroundColor(.white) 40 | .cornerRadius(8) 41 | } 42 | .frame(maxWidth: .infinity, maxHeight: .infinity) 43 | } else { 44 | ScrollView { 45 | FeaturedCarousel(events: featured, navigator: navigator) 46 | .frame(maxWidth: .infinity, maxHeight: .infinity) 47 | .frame(height: (geometry.size.height / 5) * 3.5) 48 | 49 | VStack(spacing: 24) { 50 | ForEach(talks, id: \.name) { list in 51 | TalkCarousel( 52 | title: list.name, 53 | talks: list.items, 54 | navigator: navigator, 55 | viewAllURL: URL(string: list.url) 56 | ) 57 | } 58 | 59 | ForEach(events, id: \.name) { list in 60 | EventCarousel( 61 | title: list.name, 62 | events: list.items, 63 | navigator: navigator, 64 | viewAllURL: URL(string: list.url) 65 | ) 66 | } 67 | 68 | ForEach(speakers, id: \.name) { list in 69 | SpeakerCarousel( 70 | title: list.name, 71 | speakers: list.items, 72 | navigator: navigator, 73 | viewAllURL: URL(string: list.url) 74 | ) 75 | } 76 | 77 | } 78 | .padding(.top, 24) 79 | } 80 | .edgesIgnoringSafeArea(.top) 81 | } 82 | } 83 | .onAppear { 84 | App.instance.tabBarController.hideNavigationBarFor(title: "Home") 85 | if !hasLoadedInitialData { 86 | fetchData() 87 | hasLoadedInitialData = true 88 | } 89 | }.refreshable { 90 | await refreshData() 91 | } 92 | } 93 | 94 | private func fetchData() { 95 | isLoading = true 96 | errorMessage = nil 97 | 98 | APIService.shared 99 | .fetchData(from: Router.instance.home_json_url().absoluteString) { ( 100 | result: Result 101 | ) in 102 | DispatchQueue.main.async { 103 | isLoading = false 104 | handleFetchResult(result) 105 | } 106 | } 107 | } 108 | 109 | private func refreshData() async { 110 | isLoading = true 111 | errorMessage = nil 112 | 113 | do { 114 | let response: HomeViewResponse = try await APIService.shared.fetchData(from: Router.instance.home_json_url().absoluteString) 115 | DispatchQueue.main.async { 116 | isLoading = false 117 | handleResponse(response) 118 | } 119 | } catch { 120 | DispatchQueue.main.async { 121 | isLoading = false 122 | errorMessage = error.localizedDescription 123 | } 124 | } 125 | } 126 | 127 | private func handleFetchResult(_ result: Result) { 128 | switch result { 129 | case .success(let response): 130 | handleResponse(response) 131 | case .failure(let error): 132 | self.errorMessage = error.localizedDescription 133 | } 134 | } 135 | 136 | private func handleResponse(_ response: HomeViewResponse) { 137 | featured = response.featured 138 | events = response.events 139 | talks = response.talks 140 | speakers = response.speakers 141 | } 142 | } 143 | 144 | #Preview { 145 | HomeView() 146 | } 147 | -------------------------------------------------------------------------------- /RubyEvents/views/HomeViewSkeleton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewSkeleton.swift 3 | // RubyEvents 4 | // 5 | // Created by Marco Roth on 31.03.2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HomeViewSkeleton: View { 11 | @State private var isAnimating = false 12 | 13 | var body: some View { 14 | GeometryReader { geometry in 15 | ScrollView() { 16 | RoundedRectangle(cornerRadius: 0) 17 | .fill(Color.gray.opacity(0.2)) 18 | .frame(maxWidth: .infinity) 19 | .frame(height: (geometry.size.height / 5) * 3.5) 20 | .opacity(isAnimating ? 1 : 0.2) 21 | .animation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: isAnimating) 22 | 23 | VStack(spacing: 24) { 24 | ForEach(0..<2, id: \.self) { index in 25 | VStack(alignment: .leading, spacing: 12) { 26 | RoundedRectangle(cornerRadius: 4) 27 | .fill(Color.gray.opacity(0.2)) 28 | .frame(width: 150, height: 24) 29 | .padding(.horizontal) 30 | .opacity(isAnimating ? 0.5 : 0.5) 31 | .animation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true).delay(0.1 * Double(index)), value: isAnimating) 32 | 33 | HStack(spacing: 16) { 34 | ForEach(0..<4, id: \.self) { itemIndex in 35 | RoundedRectangle(cornerRadius: 8) 36 | .fill(Color.gray.opacity(0.2)) 37 | .frame(width: 200, height: 120) 38 | .opacity(isAnimating ? 1 : 0.5) 39 | .animation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true).delay(0.1 * Double(itemIndex)), value: isAnimating) 40 | } 41 | } 42 | .padding(.horizontal) 43 | 44 | } 45 | } 46 | } 47 | .padding(.top, 24) 48 | } 49 | .edgesIgnoringSafeArea(.top) 50 | } 51 | .onAppear { 52 | App.instance.tabBarController.hideNavigationBarFor(title: "Home") 53 | isAnimating = true 54 | } 55 | } 56 | } 57 | 58 | #Preview { 59 | HomeViewSkeleton() 60 | } 61 | -------------------------------------------------------------------------------- /RubyEvents/views/PageView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PageView: View { 4 | var pages: [Page] 5 | 6 | var body: some View { 7 | PageViewController(pages: pages).aspectRatio(2 / 3, contentMode: .fit) 8 | } 9 | } 10 | 11 | #Preview { 12 | PageView(pages: [FeaturedCard(event: Event.blueRidgeRuby())]) 13 | } 14 | 15 | 16 | --------------------------------------------------------------------------------