commit 1f5f6f4883df1a0ddcaddb424bf41b5fb98ba16a Author: RTSDA Date: Mon Feb 3 16:15:57 2025 -0500 Initial commit: RTSDA iOS app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6b9254 --- /dev/null +++ b/.gitignore @@ -0,0 +1,99 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +# macOS +.DS_Store + +# Sensitive Data +GoogleService-Info.plist + +# Environment variables +*.env diff --git a/Assets.xcassets/AccentColor.colorset/Contents.json b/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/AppIcon.appiconset/Contents.json b/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9cb6138 --- /dev/null +++ b/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "filename" : "RTSDA1024x1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "RTSDA1024x1024 1.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "RTSDA1024x1024 2.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/AppIcon.appiconset/RTSDA1024x1024 1.png b/Assets.xcassets/AppIcon.appiconset/RTSDA1024x1024 1.png new file mode 100644 index 0000000..41381ec Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/RTSDA1024x1024 1.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/RTSDA1024x1024 2.png b/Assets.xcassets/AppIcon.appiconset/RTSDA1024x1024 2.png new file mode 100644 index 0000000..41381ec Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/RTSDA1024x1024 2.png differ diff --git a/Assets.xcassets/AppIcon.appiconset/RTSDA1024x1024.png b/Assets.xcassets/AppIcon.appiconset/RTSDA1024x1024.png new file mode 100644 index 0000000..41381ec Binary files /dev/null and b/Assets.xcassets/AppIcon.appiconset/RTSDA1024x1024.png differ diff --git a/Assets.xcassets/Contents.json b/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/church_hero.imageset/Contents.json b/Assets.xcassets/church_hero.imageset/Contents.json new file mode 100644 index 0000000..a2b7aa7 --- /dev/null +++ b/Assets.xcassets/church_hero.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "generated-image-1736620278588.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/church_hero.imageset/generated-image-1736620278588.png b/Assets.xcassets/church_hero.imageset/generated-image-1736620278588.png new file mode 100644 index 0000000..391278a Binary files /dev/null and b/Assets.xcassets/church_hero.imageset/generated-image-1736620278588.png differ diff --git a/Assets.xcassets/church_logo.imageset/Contents.json b/Assets.xcassets/church_logo.imageset/Contents.json new file mode 100644 index 0000000..3af9c55 --- /dev/null +++ b/Assets.xcassets/church_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "sdalogo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/church_logo.imageset/sdalogo.png b/Assets.xcassets/church_logo.imageset/sdalogo.png new file mode 100644 index 0000000..7d5a4b2 Binary files /dev/null and b/Assets.xcassets/church_logo.imageset/sdalogo.png differ diff --git a/Assets.xcassets/sdalogo.imageset/Contents.json b/Assets.xcassets/sdalogo.imageset/Contents.json new file mode 100644 index 0000000..3af9c55 --- /dev/null +++ b/Assets.xcassets/sdalogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "sdalogo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/sdalogo.imageset/sdalogo.png b/Assets.xcassets/sdalogo.imageset/sdalogo.png new file mode 100644 index 0000000..eaadbc8 Binary files /dev/null and b/Assets.xcassets/sdalogo.imageset/sdalogo.png differ diff --git a/Extensions/Color+Extensions.swift b/Extensions/Color+Extensions.swift new file mode 100644 index 0000000..b08a35e --- /dev/null +++ b/Extensions/Color+Extensions.swift @@ -0,0 +1,30 @@ +import SwiftUI + +extension Color { + static func hex(_ hex: String) -> Color { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + + return Color( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } + + static let brandOrange = Color.hex("fb8b23") +} diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..6ebb6cd --- /dev/null +++ b/Info.plist @@ -0,0 +1,76 @@ + + + + + UILaunchStoryboardName + LaunchScreen + UIBackgroundModes + + audio + remote-notification + + BGTaskSchedulerPermittedIdentifiers + + org.rockvilletollandsda.rtsda.refresh + org.rockvilletollandsda.rtsda.processing + + UIAppFonts + + Montserrat-Regular.ttf + Montserrat-SemiBold.ttf + Lora-Italic.ttf + + NSCalendarsUsageDescription + We need access to your calendar to add church events that you're interested in attending. + NSCalendarsWriteOnlyAccessUsageDescription + We can add church events to your calendar without viewing your existing events. + NSLocationWhenInUseUsageDescription + Your location is used to provide directions to church events and activities. + NSLocationAlwaysAndWhenInUseUsageDescription + Your location is used to provide directions to church events and activities. + NSCameraUsageDescription + The camera can be used to scan QR codes for event registration or take photos during church events. + NSPhotoLibraryUsageDescription + Access to your photo library allows you to share photos from church events and save event QR codes. + NSMicrophoneUsageDescription + The microphone is used for live stream audio and voice interactions during online events. + NSContactsUsageDescription + Access to contacts allows you to easily share church events with friends and family. + NSRemindersUsageDescription + Reminders can be set for upcoming church events and activities you're interested in. + NSAppleMusicUsageDescription + Access to media library is used for hymns and worship music during services. + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + LSApplicationQueriesSchemes + + sschool + com.googleusercontent.apps.443920152945-d0kf5h2dubt0jbcntq8l0qeg6lbpgn60 + egw-ios + com.whiteestate.egwwritings.syncviewer.new + adventisthymnarium + youversion + fb + tiktok + spotify + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + AVInitialRouteSharingPolicy + LongFormVideo + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..531e351 --- /dev/null +++ b/LICENSE @@ -0,0 +1,46 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + +[... Rest of GPL v3 text ...] + +-------------------------------------------------------------------------------- + +IMPORTANT NOTICE REGARDING CHURCH CONTENT: + +The content accessible through this application, including but not limited to: +- Sermon recordings and livestreams +- Event information and calendars +- Church-specific media and documents +- Religious materials and resources + +are copyrighted by the Rockville-Tolland Seventh-day Adventist Church. +All rights reserved. + +This content is protected by copyright and other intellectual property laws. +These materials are not covered by the GPL license and may not be reproduced, +distributed, or used without proper authorization from the copyright holders. + +For permissions regarding the use of church content, please contact: +Rockville-Tolland Seventh-day Adventist Church +Administrative Office +PO Box 309 Tolland CT 06084 +860-875-0450 +info0@rockvilletollandsda.org diff --git a/Managers/PermissionsManager.swift b/Managers/PermissionsManager.swift new file mode 100644 index 0000000..8808041 --- /dev/null +++ b/Managers/PermissionsManager.swift @@ -0,0 +1,264 @@ +import Foundation +import EventKit +import CoreLocation +import Photos +import Contacts +import AVFoundation + +@MainActor +class PermissionsManager: ObservableObject { + static let shared = PermissionsManager() + + @Published var calendarAccess: Bool = false + @Published var locationAccess: Bool = false + @Published var cameraAccess: Bool = false + @Published var photosAccess: Bool = false + @Published var contactsAccess: Bool = false + @Published var microphoneAccess: Bool = false + + private let locationManager = CLLocationManager() + + private init() { + checkPermissions() + } + + func checkPermissions() { + checkCalendarAccess() + checkLocationAccess() + checkCameraAccess() + checkPhotosAccess() + checkContactsAccess() + checkMicrophoneAccess() + } + + // MARK: - Calendar + func checkCalendarAccess() { + if #available(iOS 17.0, *) { + let status = EKEventStore.authorizationStatus(for: .event) + calendarAccess = status == .fullAccess || status == .writeOnly + } else { + let status = EKEventStore.authorizationStatus(for: .event) + calendarAccess = status == .authorized + } + } + + func requestCalendarAccess() async -> Bool { + let store = EKEventStore() + do { + if #available(iOS 17.0, *) { + // First try to get write-only access as it's less invasive + let writeGranted = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + store.requestWriteOnlyAccessToEvents { granted, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: granted) + } + } + } + + if writeGranted { + await MainActor.run { + calendarAccess = true + } + return true + } + + // If write-only access fails or is denied, try for full access + let fullGranted = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + store.requestFullAccessToEvents { granted, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: granted) + } + } + } + + await MainActor.run { + calendarAccess = fullGranted + } + return fullGranted + } else { + // Pre-iOS 17 fallback + #if compiler(>=5.9) + let granted = try await store.requestAccess(to: .event) + #else + let granted = try await withCheckedThrowingContinuation { continuation in + store.requestAccess(to: .event) { granted, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: granted) + } + } + } + #endif + + await MainActor.run { + calendarAccess = granted + } + return granted + } + } catch { + print("❌ Calendar access error: \(error)") + return false + } + } + + // MARK: - Location + func checkLocationAccess() { + switch locationManager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + locationAccess = true + default: + locationAccess = false + } + } + + func requestLocationAccess() { + locationManager.requestWhenInUseAuthorization() + } + + // MARK: - Camera + func checkCameraAccess() { + let status = AVCaptureDevice.authorizationStatus(for: .video) + cameraAccess = status == .authorized + } + + func requestCameraAccess() async -> Bool { + let granted = await AVCaptureDevice.requestAccess(for: .video) + await MainActor.run { + cameraAccess = granted + } + return granted + } + + // MARK: - Photos + func checkPhotosAccess() { + let status = PHPhotoLibrary.authorizationStatus() + photosAccess = status == .authorized || status == .limited + } + + func requestPhotosAccess() async -> Bool { + let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite) + await MainActor.run { + photosAccess = status == .authorized || status == .limited + } + return photosAccess + } + + // MARK: - Contacts + func checkContactsAccess() { + let status = CNContactStore.authorizationStatus(for: .contacts) + contactsAccess = status == .authorized + } + + func requestContactsAccess() async -> Bool { + let store = CNContactStore() + do { + let granted = try await store.requestAccess(for: .contacts) + await MainActor.run { + contactsAccess = granted + } + return granted + } catch { + print("❌ Contacts access error: \(error)") + return false + } + } + + // MARK: - Microphone + func checkMicrophoneAccess() { + let status = AVCaptureDevice.authorizationStatus(for: .audio) + microphoneAccess = status == .authorized + } + + func requestMicrophoneAccess() async -> Bool { + let granted = await AVCaptureDevice.requestAccess(for: .audio) + await MainActor.run { + microphoneAccess = granted + } + return granted + } + + // MARK: - Helper Methods + func handleLimitedAccess(for feature: AppFeature) -> FeatureAvailability { + switch feature { + case .calendar: + if #available(iOS 17.0, *) { + let status = EKEventStore.authorizationStatus(for: .event) + switch status { + case .fullAccess: + return .full + case .writeOnly: + return .limited + default: + return .unavailable + } + } else { + let status = EKEventStore.authorizationStatus(for: .event) + if status == .authorized { + return .full + } + return .unavailable + } + case .location: + return locationAccess ? .full : .limited + case .camera: + return cameraAccess ? .full : .limited + case .photos: + // Photos is special - it can work with limited access + let status = PHPhotoLibrary.authorizationStatus() + switch status { + case .authorized: + return .full + case .limited: + return .limited + default: + return .unavailable + } + case .contacts: + return contactsAccess ? .full : .limited + case .microphone: + return microphoneAccess ? .full : .limited + } + } +} + +enum AppFeature { + case calendar + case location + case camera + case photos + case contacts + case microphone + + var displayName: String { + switch self { + case .calendar: return "Calendar" + case .location: return "Location" + case .camera: return "Camera" + case .photos: return "Photos" + case .contacts: return "Contacts" + case .microphone: return "Microphone" + } + } +} + +enum FeatureAvailability { + case full + case limited + case unavailable + + var description: String { + switch self { + case .full: + return "Full Access" + case .limited: + return "Limited Access" + case .unavailable: + return "Not Available" + } + } +} diff --git a/Models/Config.swift b/Models/Config.swift new file mode 100644 index 0000000..7b9e52b --- /dev/null +++ b/Models/Config.swift @@ -0,0 +1,49 @@ +import Foundation + +struct Config: Codable { + let id: String + let churchName: String + let contactEmail: String + let contactPhone: String + let churchAddress: String + let googleMapsUrl: String + let aboutText: String + let apiKeys: APIKeys + + struct APIKeys: Codable { + let bibleApiKey: String + let jellyfinApiKey: String + + enum CodingKeys: String, CodingKey { + case bibleApiKey = "bible_api_key" + case jellyfinApiKey = "jellyfin_api_key" + } + } + + enum CodingKeys: String, CodingKey { + case id + case churchName = "church_name" + case contactEmail = "contact_email" + case contactPhone = "contact_phone" + case churchAddress = "church_address" + case googleMapsUrl = "google_maps_url" + case aboutText = "about_text" + case apiKeys = "api_key" + } +} + +struct ConfigResponse: Codable { + let page: Int + let perPage: Int + let totalPages: Int + let totalItems: Int + let items: [Config] + + enum CodingKeys: String, CodingKey { + case page + case perPage = "perPage" + case totalPages = "totalPages" + case totalItems = "totalItems" + case items + } +} \ No newline at end of file diff --git a/Models/Event.swift b/Models/Event.swift new file mode 100644 index 0000000..aab72ba --- /dev/null +++ b/Models/Event.swift @@ -0,0 +1,400 @@ +import Foundation +import EventKit +import UIKit + +struct Event: Identifiable, Codable { + let id: String + let title: String + let description: String // Original HTML description + let startDate: Date + let endDate: Date + let location: String? + let locationURL: String? + let image: String? + let thumbnail: String? + let category: EventCategory + let isFeatured: Bool + let reoccuring: ReoccurringType + let isPublished: Bool + let created: Date + let updated: Date + + enum EventCategory: String, Codable { + case service = "Service" + case social = "Social" + case ministry = "Ministry" + case other = "Other" + } + + enum ReoccurringType: String, Codable { + case none = "" // For non-recurring events + case daily = "DAILY" + case weekly = "WEEKLY" + case biweekly = "BIWEEKLY" + case firstTuesday = "FIRST_TUESDAY" + + var calendarRecurrenceRule: EKRecurrenceRule? { + switch self { + case .none: + return nil // No recurrence for one-time events + case .daily: + return EKRecurrenceRule( + recurrenceWith: .daily, + interval: 1, + end: nil + ) + case .weekly: + return EKRecurrenceRule( + recurrenceWith: .weekly, + interval: 1, + end: nil + ) + case .biweekly: + return EKRecurrenceRule( + recurrenceWith: .weekly, + interval: 2, + end: nil + ) + case .firstTuesday: + let tuesday = EKWeekday.tuesday + return EKRecurrenceRule( + recurrenceWith: .monthly, + interval: 1, + daysOfTheWeek: [EKRecurrenceDayOfWeek(tuesday)], + daysOfTheMonth: nil, + monthsOfTheYear: nil, + weeksOfTheYear: nil, + daysOfTheYear: nil, + setPositions: [1], + end: nil + ) + } + } + } + + var formattedDateTime: String { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + dateFormatter.timeZone = .gmt // Use GMT to match database times exactly + + let startDateString = dateFormatter.string(from: startDate) + let endTimeFormatter = DateFormatter() + endTimeFormatter.timeStyle = .short + endTimeFormatter.timeZone = .gmt // Use GMT to match database times exactly + let endTimeString = endTimeFormatter.string(from: endDate) + + return "\(startDateString) • \(endTimeString)" + } + + var hasLocation: Bool { + return (location != nil && !location!.isEmpty) + } + + var hasLocationUrl: Bool { + return (locationURL != nil && !locationURL!.isEmpty) + } + + var canOpenInMaps: Bool { + return hasLocation + } + + var displayLocation: String { + if let location = location { + return location + } + if let locationURL = locationURL { + // Try to extract a readable location from the URL + if let url = URL(string: locationURL) { + let components = url.pathComponents + if components.count > 1 { + return components.last?.replacingOccurrences(of: "+", with: " ") ?? locationURL + } + } + return locationURL + } + return "No location specified" + } + + var imageURL: URL? { + guard let image = image else { return nil } + return URL(string: "https://pocketbase.rockvilletollandsda.church/api/files/events/\(id)/\(image)") + } + + var thumbnailURL: URL? { + guard let thumbnail = thumbnail else { return nil } + return URL(string: "https://pocketbase.rockvilletollandsda.church/api/files/events/\(id)/\(thumbnail)") + } + + func callPhone() { + if let phoneNumber = extractPhoneNumber() { + let cleanNumber = phoneNumber.replacingOccurrences(of: "[^0-9+]", with: "", options: .regularExpression) + if let url = URL(string: "tel://\(cleanNumber)") { + UIApplication.shared.open(url) + } + } + } + + func extractPhoneNumber() -> String? { + let phonePattern = #"Phone:.*?(\+\d{1})?[\s-]?\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})"# + if let match = description.range(of: phonePattern, options: .regularExpression) { + let phoneText = String(description[match]) + let numberPattern = #"(\+\d{1})?[\s-]?\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})"# + if let numberMatch = phoneText.range(of: numberPattern, options: .regularExpression) { + return String(phoneText[numberMatch]) + } + } + return nil + } + + var plainDescription: String { + // First remove all table structures and divs + var cleanedText = description.replacingOccurrences(of: "]*>.*?", with: "", options: .regularExpression) + cleanedText = cleanedText.replacingOccurrences(of: "]*>", with: "", options: .regularExpression) + cleanedText = cleanedText.replacingOccurrences(of: "", with: "\n", options: .regularExpression) + + // Replace other HTML tags + cleanedText = cleanedText.replacingOccurrences(of: "", with: "\n", options: .regularExpression) + cleanedText = cleanedText.replacingOccurrences(of: "

", with: "", options: .regularExpression) + cleanedText = cleanedText.replacingOccurrences(of: "

", with: "\n", options: .regularExpression) + cleanedText = cleanedText.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + + // Decode common HTML entities + let htmlEntities = [ + " ": " ", + "&": "&", + "<": "<", + ">": ">", + """: "\"", + "'": "'", + "'": "'", + "/": "/", + "'": "'", + "/": "/", + "’": "'", + "—": "—" + ] + + for (entity, replacement) in htmlEntities { + cleanedText = cleanedText.replacingOccurrences(of: entity, with: replacement) + } + + // Format phone numbers with better pattern matching + let phonePattern = #"(?m)^Phone:.*?(\+\d{1})?[\s-]?\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})"# + cleanedText = cleanedText.replacingOccurrences( + of: phonePattern, + with: "📞 Phone: ($2) $3-$4", + options: .regularExpression + ) + + // Clean up whitespace while preserving intentional line breaks + let lines = cleanedText.components(separatedBy: .newlines) + let nonEmptyLines = lines.map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + return nonEmptyLines.joined(separator: "\n") + } + + func openInMaps() async { + let permissionsManager = await PermissionsManager.shared + + // We don't strictly need location permission to open maps, + // but we'll request it for better functionality + await permissionsManager.requestLocationAccess() + + if let locationURL = locationURL, let url = URL(string: locationURL) { + await UIApplication.shared.open(url, options: [:]) + } else if let location = location, !location.isEmpty { + let searchQuery = location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? location + if let mapsUrl = URL(string: "http://maps.apple.com/?q=\(searchQuery)") { + await UIApplication.shared.open(mapsUrl, options: [:]) + } + } + } + + func addToCalendar(completion: @escaping (Bool, Error?) -> Void) async { + let permissionsManager = await PermissionsManager.shared + let eventStore = EKEventStore() + + do { + let accessGranted = await permissionsManager.requestCalendarAccess() + + if !accessGranted { + await MainActor.run { + completion(false, NSError(domain: "com.rtsda.calendar", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Calendar access is not available. You can enable it in Settings."])) + } + return + } + + let event = EKEvent(eventStore: eventStore) + + // Set basic event details + event.title = self.title + event.notes = self.plainDescription + event.startDate = self.startDate + event.endDate = self.endDate + event.location = self.location ?? self.locationURL + + // Set recurrence rule if applicable + if let rule = self.reoccuring.calendarRecurrenceRule { + event.recurrenceRules = [rule] + } + + // Get the default calendar + guard let calendar = eventStore.defaultCalendarForNewEvents else { + await MainActor.run { + completion(false, NSError(domain: "com.rtsda.calendar", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Could not access default calendar."])) + } + return + } + + event.calendar = calendar + + try eventStore.save(event, span: .thisEvent) + await MainActor.run { + completion(true, nil) + } + } catch { + await MainActor.run { + completion(false, error) + } + } + } + + enum CodingKeys: String, CodingKey { + case id + case title + case description + case startDate = "start_time" + case endDate = "end_time" + case location + case locationURL = "location_url" + case image + case thumbnail + case category + case isFeatured = "is_featured" + case reoccuring + case isPublished = "is_published" + case created + case updated + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: .id) + title = try container.decode(String.self, forKey: .title) + description = try container.decode(String.self, forKey: .description) + + // Try multiple date formats + let startDateString = try container.decode(String.self, forKey: .startDate) + let endDateString = try container.decode(String.self, forKey: .endDate) + let createdString = try container.decode(String.self, forKey: .created) + let updatedString = try container.decode(String.self, forKey: .updated) + + // Create formatters for different possible formats + let formatters = [ + { () -> DateFormatter in + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" // PocketBase format + return formatter + }(), + { () -> ISO8601DateFormatter in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }(), + { () -> ISO8601DateFormatter in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + ] + + // Function to try parsing date with multiple formatters + func parseDate(_ dateString: String, field: String) throws -> Date { + // Print the date string we're trying to parse + print("🗓️ Trying to parse date: \(dateString) for field: \(field)") + + for formatter in formatters { + if let date = (formatter as? ISO8601DateFormatter)?.date(from: dateString) ?? + (formatter as? DateFormatter)?.date(from: dateString) { + print("✅ Successfully parsed date using \(type(of: formatter))") + return date + } + } + + throw DecodingError.dataCorruptedError( + forKey: CodingKeys(stringValue: field)!, + in: container, + debugDescription: "Date string '\(dateString)' does not match any expected format" + ) + } + + // Parse all dates + startDate = try parseDate(startDateString, field: "start_time") + endDate = try parseDate(endDateString, field: "end_time") + created = try parseDate(createdString, field: "created") + updated = try parseDate(updatedString, field: "updated") + + // Decode remaining fields + location = try container.decodeIfPresent(String.self, forKey: .location) + locationURL = try container.decodeIfPresent(String.self, forKey: .locationURL) + image = try container.decodeIfPresent(String.self, forKey: .image) + thumbnail = try container.decodeIfPresent(String.self, forKey: .thumbnail) + category = try container.decode(EventCategory.self, forKey: .category) + isFeatured = try container.decode(Bool.self, forKey: .isFeatured) + reoccuring = try container.decode(ReoccurringType.self, forKey: .reoccuring) + isPublished = try container.decodeIfPresent(Bool.self, forKey: .isPublished) ?? true // Default to true if not present + } + + init(id: String = UUID().uuidString, + title: String, + description: String, + startDate: Date, + endDate: Date, + location: String? = nil, + locationURL: String? = nil, + image: String? = nil, + thumbnail: String? = nil, + category: EventCategory, + isFeatured: Bool = false, + reoccuring: ReoccurringType, + isPublished: Bool = true, + created: Date = Date(), + updated: Date = Date()) { + self.id = id + self.title = title + self.description = description + self.startDate = startDate + self.endDate = endDate + self.location = location + self.locationURL = locationURL + self.image = image + self.thumbnail = thumbnail + self.category = category + self.isFeatured = isFeatured + self.reoccuring = reoccuring + self.isPublished = isPublished + self.created = created + self.updated = updated + } +} + +struct EventResponse: Codable { + let page: Int + let perPage: Int + let totalPages: Int + let totalItems: Int + let items: [Event] + + enum CodingKeys: String, CodingKey { + case page + case perPage = "perPage" + case totalPages = "totalPages" + case totalItems = "totalItems" + case items + } +} diff --git a/Models/Message.swift b/Models/Message.swift new file mode 100644 index 0000000..05bf21b --- /dev/null +++ b/Models/Message.swift @@ -0,0 +1,57 @@ +import Foundation + +struct Message: Identifiable { + let id: String + let title: String + let description: String + let speaker: String + let videoUrl: String + let thumbnailUrl: String? + let duration: TimeInterval + let isLiveStream: Bool + let isPublished: Bool + let isDeleted: Bool + let liveBroadcastStatus: String // "none", "upcoming", "live", or "completed" + let date: String // ISO8601 formatted date string + + var formattedDuration: String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .abbreviated + formatter.maximumUnitCount = 2 + return formatter.string(from: duration) ?? "" + } + + var formattedDate: String { + // Parse the date string + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.timeZone = TimeZone(identifier: "America/New_York") + + guard let date = dateFormatter.date(from: date) else { return date } + + // Format for display + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "MMMM d, yyyy" + displayFormatter.timeZone = TimeZone(identifier: "America/New_York") + return displayFormatter.string(from: date) + } +} + +// MARK: - Codable +extension Message: Codable { + enum CodingKeys: String, CodingKey { + case id + case title + case description + case speaker + case videoUrl + case thumbnailUrl + case duration + case isLiveStream + case isPublished + case isDeleted + case liveBroadcastStatus + case date + } +} diff --git a/Models/Sermon.swift b/Models/Sermon.swift new file mode 100644 index 0000000..9a83ce4 --- /dev/null +++ b/Models/Sermon.swift @@ -0,0 +1,28 @@ +import Foundation + +struct Sermon: Identifiable { + let id: String + let title: String + let description: String + let date: Date + let speaker: String + let type: SermonType + let videoUrl: String? + let thumbnail: String? + + init(id: String, title: String, description: String, date: Date, speaker: String, type: SermonType, videoUrl: String?, thumbnail: String?) { + self.id = id + self.title = title + self.description = description + self.date = date + self.speaker = speaker + self.type = type + self.videoUrl = videoUrl + self.thumbnail = thumbnail + } +} + +enum SermonType: String { + case sermon = "Sermons" + case liveArchive = "LiveStreams" +} diff --git a/Preview Content/Preview Assets.xcassets/Contents.json b/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RTSDA.entitlements b/RTSDA.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/RTSDA.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/RTSDA.xcodeproj/project.pbxproj b/RTSDA.xcodeproj/project.pbxproj new file mode 100644 index 0000000..5ed6f48 --- /dev/null +++ b/RTSDA.xcodeproj/project.pbxproj @@ -0,0 +1,589 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + EA1C83A42D43EA4900D8B78F /* SermonBrowserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */; }; + EA1C83A52D43EA4900D8B78F /* RTSDAApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */; }; + EA1C83A62D43EA4900D8B78F /* EventsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C838C2D43EA4900D8B78F /* EventsViewModel.swift */; }; + EA1C83A72D43EA4900D8B78F /* LivestreamCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839A2D43EA4900D8B78F /* LivestreamCard.swift */; }; + EA1C83A92D43EA4900D8B78F /* Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83622D43EA4900D8B78F /* Event.swift */; }; + EA1C83AA2D43EA4900D8B78F /* MessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839C2D43EA4900D8B78F /* MessagesView.swift */; }; + EA1C83AB2D43EA4900D8B78F /* EventsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83982D43EA4900D8B78F /* EventsView.swift */; }; + EA1C83AC2D43EA4900D8B78F /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83972D43EA4900D8B78F /* EventDetailView.swift */; }; + EA1C83AD2D43EA4900D8B78F /* CachedAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83932D43EA4900D8B78F /* CachedAsyncImage.swift */; }; + EA1C83AE2D43EA4900D8B78F /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83792D43EA4900D8B78F /* ImageCache.swift */; }; + EA1C83AF2D43EA4900D8B78F /* OwncastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839D2D43EA4900D8B78F /* OwncastView.swift */; }; + EA1C83B02D43EA4900D8B78F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83952D43EA4900D8B78F /* ContentView.swift */; }; + EA1C83B12D43EA4900D8B78F /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83612D43EA4900D8B78F /* Config.swift */; }; + EA1C83B22D43EA4900D8B78F /* OwncastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */; }; + EA1C83B32D43EA4900D8B78F /* PocketBaseService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C837C2D43EA4900D8B78F /* PocketBaseService.swift */; }; + EA1C83B42D43EA4900D8B78F /* ContactFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83942D43EA4900D8B78F /* ContactFormView.swift */; }; + EA1C83B52D43EA4900D8B78F /* JellyfinService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C837A2D43EA4900D8B78F /* JellyfinService.swift */; }; + EA1C83B62D43EA4900D8B78F /* EventCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83962D43EA4900D8B78F /* EventCard.swift */; }; + EA1C83B72D43EA4900D8B78F /* ConfigService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83782D43EA4900D8B78F /* ConfigService.swift */; }; + EA1C83B82D43EA4900D8B78F /* MessageCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839B2D43EA4900D8B78F /* MessageCard.swift */; }; + EA1C83B92D43EA4900D8B78F /* Sermon.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83642D43EA4900D8B78F /* Sermon.swift */; }; + EA1C83BA2D43EA4900D8B78F /* PermissionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C835F2D43EA4900D8B78F /* PermissionsManager.swift */; }; + EA1C83BC2D43EA4900D8B78F /* BeliefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83912D43EA4900D8B78F /* BeliefsView.swift */; }; + EA1C83BD2D43EA4900D8B78F /* BulletinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83922D43EA4900D8B78F /* BulletinView.swift */; }; + EA1C83BE2D43EA4900D8B78F /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83632D43EA4900D8B78F /* Message.swift */; }; + EA1C83BF2D43EA4900D8B78F /* BibleService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83772D43EA4900D8B78F /* BibleService.swift */; }; + EA1C83C02D43EA4900D8B78F /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */; }; + EA1C83C12D43EA4900D8B78F /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839F2D43EA4900D8B78F /* VideoPlayerView.swift */; }; + EA1C83C22D43EA4900D8B78F /* MessagesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */; }; + EA1C83C32D43EA4900D8B78F /* OwnCastService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C837B2D43EA4900D8B78F /* OwnCastService.swift */; }; + EA1C83C42D43EA4900D8B78F /* SplashScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839E2D43EA4900D8B78F /* SplashScreenView.swift */; }; + EA1C83C52D43EA4900D8B78F /* JellyfinPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83992D43EA4900D8B78F /* JellyfinPlayerView.swift */; }; + EA1C83C62D43EA4900D8B78F /* AppAvailabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83762D43EA4900D8B78F /* AppAvailabilityService.swift */; }; + EA1C83C72D43EA4900D8B78F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EA1C83662D43EA4900D8B78F /* Preview Assets.xcassets */; }; + EA1C83C92D43EA4900D8B78F /* Lora-Italic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C83682D43EA4900D8B78F /* Lora-Italic.ttf */; }; + EA1C83CD2D43EA4900D8B78F /* Lora-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C83692D43EA4900D8B78F /* Lora-Regular.ttf */; }; + EA1C83CE2D43EA4900D8B78F /* Montserrat-SemiBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C836B2D43EA4900D8B78F /* Montserrat-SemiBold.ttf */; }; + EA1C83CF2D43EA4900D8B78F /* Montserrat-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C836A2D43EA4900D8B78F /* Montserrat-Regular.ttf */; }; + EA1C83D22D43EA4900D8B78F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EA1C835B2D43EA4900D8B78F /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + EA07AAFA2D43EF78002ACBF8 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + EA1C835B2D43EA4900D8B78F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; + EA1C835E2D43EA4900D8B78F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + EA1C835F2D43EA4900D8B78F /* PermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsManager.swift; sourceTree = ""; }; + EA1C83612D43EA4900D8B78F /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; + EA1C83622D43EA4900D8B78F /* Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = ""; }; + EA1C83632D43EA4900D8B78F /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; + EA1C83642D43EA4900D8B78F /* Sermon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sermon.swift; sourceTree = ""; }; + EA1C83662D43EA4900D8B78F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + EA1C83682D43EA4900D8B78F /* Lora-Italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Lora-Italic.ttf"; sourceTree = ""; }; + EA1C83692D43EA4900D8B78F /* Lora-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Lora-Regular.ttf"; sourceTree = ""; }; + EA1C836A2D43EA4900D8B78F /* Montserrat-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Montserrat-Regular.ttf"; sourceTree = ""; }; + EA1C836B2D43EA4900D8B78F /* Montserrat-SemiBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Montserrat-SemiBold.ttf"; sourceTree = ""; }; + EA1C836E2D43EA4900D8B78F /* RTSDA.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RTSDA.entitlements; sourceTree = ""; }; + EA1C836F2D43EA4900D8B78F /* RTSDA.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = RTSDA.xcodeproj; sourceTree = ""; }; + EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTSDAApp.swift; sourceTree = ""; }; + EA1C83762D43EA4900D8B78F /* AppAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAvailabilityService.swift; sourceTree = ""; }; + EA1C83772D43EA4900D8B78F /* BibleService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BibleService.swift; sourceTree = ""; }; + EA1C83782D43EA4900D8B78F /* ConfigService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigService.swift; sourceTree = ""; }; + EA1C83792D43EA4900D8B78F /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; + EA1C837A2D43EA4900D8B78F /* JellyfinService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinService.swift; sourceTree = ""; }; + EA1C837B2D43EA4900D8B78F /* OwnCastService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OwnCastService.swift; sourceTree = ""; }; + EA1C837C2D43EA4900D8B78F /* PocketBaseService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PocketBaseService.swift; sourceTree = ""; }; + EA1C838C2D43EA4900D8B78F /* EventsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsViewModel.swift; sourceTree = ""; }; + EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesViewModel.swift; sourceTree = ""; }; + EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OwncastViewModel.swift; sourceTree = ""; }; + EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SermonBrowserViewModel.swift; sourceTree = ""; }; + EA1C83912D43EA4900D8B78F /* BeliefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeliefsView.swift; sourceTree = ""; }; + EA1C83922D43EA4900D8B78F /* BulletinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulletinView.swift; sourceTree = ""; }; + EA1C83932D43EA4900D8B78F /* CachedAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedAsyncImage.swift; sourceTree = ""; }; + EA1C83942D43EA4900D8B78F /* ContactFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactFormView.swift; sourceTree = ""; }; + EA1C83952D43EA4900D8B78F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + EA1C83962D43EA4900D8B78F /* EventCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCard.swift; sourceTree = ""; }; + EA1C83972D43EA4900D8B78F /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = ""; }; + EA1C83982D43EA4900D8B78F /* EventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsView.swift; sourceTree = ""; }; + EA1C83992D43EA4900D8B78F /* JellyfinPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinPlayerView.swift; sourceTree = ""; }; + EA1C839A2D43EA4900D8B78F /* LivestreamCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamCard.swift; sourceTree = ""; }; + EA1C839B2D43EA4900D8B78F /* MessageCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCard.swift; sourceTree = ""; }; + EA1C839C2D43EA4900D8B78F /* MessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesView.swift; sourceTree = ""; }; + EA1C839D2D43EA4900D8B78F /* OwncastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OwncastView.swift; sourceTree = ""; }; + EA1C839E2D43EA4900D8B78F /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = ""; }; + EA1C839F2D43EA4900D8B78F /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; + EA2F9F7E2CF406E800B9F454 /* RTSDA.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RTSDA.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + EA2F9F7B2CF406E800B9F454 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + EA07AAF92D43EF78002ACBF8 /* Frameworks */ = { + isa = PBXGroup; + children = ( + EA07AAFA2D43EF78002ACBF8 /* XCTest.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + EA1C835D2D43EA4900D8B78F /* Extensions */ = { + isa = PBXGroup; + children = ( + EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + EA1C83602D43EA4900D8B78F /* Managers */ = { + isa = PBXGroup; + children = ( + EA1C835F2D43EA4900D8B78F /* PermissionsManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; + EA1C83652D43EA4900D8B78F /* Models */ = { + isa = PBXGroup; + children = ( + EA1C83612D43EA4900D8B78F /* Config.swift */, + EA1C83622D43EA4900D8B78F /* Event.swift */, + EA1C83632D43EA4900D8B78F /* Message.swift */, + EA1C83642D43EA4900D8B78F /* Sermon.swift */, + ); + path = Models; + sourceTree = ""; + }; + EA1C83672D43EA4900D8B78F /* Preview Content */ = { + isa = PBXGroup; + children = ( + EA1C83662D43EA4900D8B78F /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + EA1C836C2D43EA4900D8B78F /* Fonts */ = { + isa = PBXGroup; + children = ( + EA1C83682D43EA4900D8B78F /* Lora-Italic.ttf */, + EA1C83692D43EA4900D8B78F /* Lora-Regular.ttf */, + EA1C836A2D43EA4900D8B78F /* Montserrat-Regular.ttf */, + EA1C836B2D43EA4900D8B78F /* Montserrat-SemiBold.ttf */, + ); + path = Fonts; + sourceTree = ""; + }; + EA1C836D2D43EA4900D8B78F /* Resources */ = { + isa = PBXGroup; + children = ( + EA1C836C2D43EA4900D8B78F /* Fonts */, + ); + path = Resources; + sourceTree = ""; + }; + EA1C837D2D43EA4900D8B78F /* Services */ = { + isa = PBXGroup; + children = ( + EA1C83762D43EA4900D8B78F /* AppAvailabilityService.swift */, + EA1C83772D43EA4900D8B78F /* BibleService.swift */, + EA1C83782D43EA4900D8B78F /* ConfigService.swift */, + EA1C83792D43EA4900D8B78F /* ImageCache.swift */, + EA1C837A2D43EA4900D8B78F /* JellyfinService.swift */, + EA1C837B2D43EA4900D8B78F /* OwnCastService.swift */, + EA1C837C2D43EA4900D8B78F /* PocketBaseService.swift */, + ); + path = Services; + sourceTree = ""; + }; + EA1C83902D43EA4900D8B78F /* ViewModels */ = { + isa = PBXGroup; + children = ( + EA1C838C2D43EA4900D8B78F /* EventsViewModel.swift */, + EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */, + EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */, + EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + EA1C83A02D43EA4900D8B78F /* Views */ = { + isa = PBXGroup; + children = ( + EA1C83912D43EA4900D8B78F /* BeliefsView.swift */, + EA1C83922D43EA4900D8B78F /* BulletinView.swift */, + EA1C83932D43EA4900D8B78F /* CachedAsyncImage.swift */, + EA1C83942D43EA4900D8B78F /* ContactFormView.swift */, + EA1C83952D43EA4900D8B78F /* ContentView.swift */, + EA1C83962D43EA4900D8B78F /* EventCard.swift */, + EA1C83972D43EA4900D8B78F /* EventDetailView.swift */, + EA1C83982D43EA4900D8B78F /* EventsView.swift */, + EA1C83992D43EA4900D8B78F /* JellyfinPlayerView.swift */, + EA1C839A2D43EA4900D8B78F /* LivestreamCard.swift */, + EA1C839B2D43EA4900D8B78F /* MessageCard.swift */, + EA1C839C2D43EA4900D8B78F /* MessagesView.swift */, + EA1C839D2D43EA4900D8B78F /* OwncastView.swift */, + EA1C839E2D43EA4900D8B78F /* SplashScreenView.swift */, + EA1C839F2D43EA4900D8B78F /* VideoPlayerView.swift */, + ); + path = Views; + sourceTree = ""; + }; + EA1C83A12D43EA4900D8B78F /* Products */ = { + isa = PBXGroup; + name = Products; + sourceTree = ""; + }; + EA2F9F752CF406E800B9F454 = { + isa = PBXGroup; + children = ( + EA1C835B2D43EA4900D8B78F /* Assets.xcassets */, + EA1C835D2D43EA4900D8B78F /* Extensions */, + EA1C835E2D43EA4900D8B78F /* Info.plist */, + EA1C83602D43EA4900D8B78F /* Managers */, + EA1C83652D43EA4900D8B78F /* Models */, + EA1C83672D43EA4900D8B78F /* Preview Content */, + EA1C836D2D43EA4900D8B78F /* Resources */, + EA1C836E2D43EA4900D8B78F /* RTSDA.entitlements */, + EA1C836F2D43EA4900D8B78F /* RTSDA.xcodeproj */, + EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */, + EA1C837D2D43EA4900D8B78F /* Services */, + EA1C83902D43EA4900D8B78F /* ViewModels */, + EA1C83A02D43EA4900D8B78F /* Views */, + EA07AAF92D43EF78002ACBF8 /* Frameworks */, + EA2F9F7F2CF406E800B9F454 /* Products */, + ); + sourceTree = ""; + }; + EA2F9F7F2CF406E800B9F454 /* Products */ = { + isa = PBXGroup; + children = ( + EA2F9F7E2CF406E800B9F454 /* RTSDA.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + EA2F9F7D2CF406E800B9F454 /* RTSDA */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA2F9FA22CF406E900B9F454 /* Build configuration list for PBXNativeTarget "RTSDA" */; + buildPhases = ( + EA2F9F7A2CF406E800B9F454 /* Sources */, + EA2F9F7B2CF406E800B9F454 /* Frameworks */, + EA2F9F7C2CF406E800B9F454 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RTSDA; + packageProductDependencies = ( + ); + productName = RTSDA; + productReference = EA2F9F7E2CF406E800B9F454 /* RTSDA.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + EA2F9F762CF406E800B9F454 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1610; + LastUpgradeCheck = 1620; + TargetAttributes = { + EA2F9F7D2CF406E800B9F454 = { + CreatedOnToolsVersion = 16.1; + }; + }; + }; + buildConfigurationList = EA2F9F792CF406E800B9F454 /* Build configuration list for PBXProject "RTSDA" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = EA2F9F752CF406E800B9F454; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + ); + preferredProjectObjectVersion = 77; + productRefGroup = EA2F9F7F2CF406E800B9F454 /* Products */; + projectDirPath = ""; + projectReferences = ( + { + ProductGroup = EA1C83A12D43EA4900D8B78F /* Products */; + ProjectRef = EA1C836F2D43EA4900D8B78F /* RTSDA.xcodeproj */; + }, + ); + projectRoot = ""; + targets = ( + EA2F9F7D2CF406E800B9F454 /* RTSDA */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + EA2F9F7C2CF406E800B9F454 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EA1C83C72D43EA4900D8B78F /* Preview Assets.xcassets in Resources */, + EA1C83C92D43EA4900D8B78F /* Lora-Italic.ttf in Resources */, + EA1C83CD2D43EA4900D8B78F /* Lora-Regular.ttf in Resources */, + EA1C83CE2D43EA4900D8B78F /* Montserrat-SemiBold.ttf in Resources */, + EA1C83CF2D43EA4900D8B78F /* Montserrat-Regular.ttf in Resources */, + EA1C83D22D43EA4900D8B78F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + EA2F9F7A2CF406E800B9F454 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EA1C83A42D43EA4900D8B78F /* SermonBrowserViewModel.swift in Sources */, + EA1C83A52D43EA4900D8B78F /* RTSDAApp.swift in Sources */, + EA1C83A62D43EA4900D8B78F /* EventsViewModel.swift in Sources */, + EA1C83A72D43EA4900D8B78F /* LivestreamCard.swift in Sources */, + EA1C83A92D43EA4900D8B78F /* Event.swift in Sources */, + EA1C83AA2D43EA4900D8B78F /* MessagesView.swift in Sources */, + EA1C83AB2D43EA4900D8B78F /* EventsView.swift in Sources */, + EA1C83AC2D43EA4900D8B78F /* EventDetailView.swift in Sources */, + EA1C83AD2D43EA4900D8B78F /* CachedAsyncImage.swift in Sources */, + EA1C83AE2D43EA4900D8B78F /* ImageCache.swift in Sources */, + EA1C83AF2D43EA4900D8B78F /* OwncastView.swift in Sources */, + EA1C83B02D43EA4900D8B78F /* ContentView.swift in Sources */, + EA1C83B12D43EA4900D8B78F /* Config.swift in Sources */, + EA1C83B22D43EA4900D8B78F /* OwncastViewModel.swift in Sources */, + EA1C83B32D43EA4900D8B78F /* PocketBaseService.swift in Sources */, + EA1C83B42D43EA4900D8B78F /* ContactFormView.swift in Sources */, + EA1C83B52D43EA4900D8B78F /* JellyfinService.swift in Sources */, + EA1C83B62D43EA4900D8B78F /* EventCard.swift in Sources */, + EA1C83B72D43EA4900D8B78F /* ConfigService.swift in Sources */, + EA1C83B82D43EA4900D8B78F /* MessageCard.swift in Sources */, + EA1C83B92D43EA4900D8B78F /* Sermon.swift in Sources */, + EA1C83BA2D43EA4900D8B78F /* PermissionsManager.swift in Sources */, + EA1C83BC2D43EA4900D8B78F /* BeliefsView.swift in Sources */, + EA1C83BD2D43EA4900D8B78F /* BulletinView.swift in Sources */, + EA1C83BE2D43EA4900D8B78F /* Message.swift in Sources */, + EA1C83BF2D43EA4900D8B78F /* BibleService.swift in Sources */, + EA1C83C02D43EA4900D8B78F /* Color+Extensions.swift in Sources */, + EA1C83C12D43EA4900D8B78F /* VideoPlayerView.swift in Sources */, + EA1C83C22D43EA4900D8B78F /* MessagesViewModel.swift in Sources */, + EA1C83C32D43EA4900D8B78F /* OwnCastService.swift in Sources */, + EA1C83C42D43EA4900D8B78F /* SplashScreenView.swift in Sources */, + EA1C83C52D43EA4900D8B78F /* JellyfinPlayerView.swift in Sources */, + EA1C83C62D43EA4900D8B78F /* AppAvailabilityService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + EA2F9FA02CF406E900B9F454 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + NEW_SETTING = ""; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + EA2F9FA12CF406E900B9F454 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + NEW_SETTING = ""; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + EA2F9FA32CF406E900B9F454 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RTSDA.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; + DEVELOPMENT_TEAM = TQMND62F2W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.reference"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + SYSTEM_FRAMEWORK_SEARCH_PATHS = ""; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + EA2F9FA42CF406E900B9F454 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RTSDA.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; + DEVELOPMENT_TEAM = TQMND62F2W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.reference"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + SYSTEM_FRAMEWORK_SEARCH_PATHS = ""; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + EA2F9F792CF406E800B9F454 /* Build configuration list for PBXProject "RTSDA" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA2F9FA02CF406E900B9F454 /* Debug */, + EA2F9FA12CF406E900B9F454 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EA2F9FA22CF406E900B9F454 /* Build configuration list for PBXNativeTarget "RTSDA" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA2F9FA32CF406E900B9F454 /* Debug */, + EA2F9FA42CF406E900B9F454 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = EA2F9F762CF406E800B9F454 /* Project object */; +} diff --git a/RTSDA.xcodeproj/project.pbxproj.backup b/RTSDA.xcodeproj/project.pbxproj.backup new file mode 100644 index 0000000..971c4f7 --- /dev/null +++ b/RTSDA.xcodeproj/project.pbxproj.backup @@ -0,0 +1,567 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + EA2F9F8F2CF406E900B9F454 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA2F9F762CF406E800B9F454 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EA2F9F7D2CF406E800B9F454; + remoteInfo = RTSDA; + }; + EA2F9F992CF406E900B9F454 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EA2F9F762CF406E800B9F454 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EA2F9F7D2CF406E800B9F454; + remoteInfo = RTSDA; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + EA2F9F7E2CF406E800B9F454 /* RTSDA.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RTSDA.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EA2F9F8E2CF406E900B9F454 /* RTSDATests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RTSDATests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + EA2F9F982CF406E900B9F454 /* RTSDAUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RTSDAUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + EA2F9F912CF406E900B9F454 /* RTSDATests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = RTSDATests; + sourceTree = ""; + }; + EA2F9F9B2CF406E900B9F454 /* RTSDAUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = RTSDAUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + EA2F9F7B2CF406E800B9F454 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA2F9F8B2CF406E900B9F454 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA2F9F952CF406E900B9F454 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + EA2F9F752CF406E800B9F454 = { + isa = PBXGroup; + children = ( + EA2F9F912CF406E900B9F454 /* RTSDATests */, + EA2F9F9B2CF406E900B9F454 /* RTSDAUITests */, + EA2F9F7F2CF406E800B9F454 /* Products */, + ); + sourceTree = ""; + }; + EA2F9F7F2CF406E800B9F454 /* Products */ = { + isa = PBXGroup; + children = ( + EA2F9F7E2CF406E800B9F454 /* RTSDA.app */, + EA2F9F8E2CF406E900B9F454 /* RTSDATests.xctest */, + EA2F9F982CF406E900B9F454 /* RTSDAUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + EA2F9F7D2CF406E800B9F454 /* RTSDA */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA2F9FA22CF406E900B9F454 /* Build configuration list for PBXNativeTarget "RTSDA" */; + buildPhases = ( + EA2F9F7A2CF406E800B9F454 /* Sources */, + EA2F9F7B2CF406E800B9F454 /* Frameworks */, + EA2F9F7C2CF406E800B9F454 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = RTSDA; + packageProductDependencies = ( + ); + productName = RTSDA; + productReference = EA2F9F7E2CF406E800B9F454 /* RTSDA.app */; + productType = "com.apple.product-type.application"; + }; + EA2F9F8D2CF406E900B9F454 /* RTSDATests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA2F9FA52CF406E900B9F454 /* Build configuration list for PBXNativeTarget "RTSDATests" */; + buildPhases = ( + EA2F9F8A2CF406E900B9F454 /* Sources */, + EA2F9F8B2CF406E900B9F454 /* Frameworks */, + EA2F9F8C2CF406E900B9F454 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EA2F9F902CF406E900B9F454 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + EA2F9F912CF406E900B9F454 /* RTSDATests */, + ); + name = RTSDATests; + packageProductDependencies = ( + ); + productName = RTSDATests; + productReference = EA2F9F8E2CF406E900B9F454 /* RTSDATests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + EA2F9F972CF406E900B9F454 /* RTSDAUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA2F9FA82CF406E900B9F454 /* Build configuration list for PBXNativeTarget "RTSDAUITests" */; + buildPhases = ( + EA2F9F942CF406E900B9F454 /* Sources */, + EA2F9F952CF406E900B9F454 /* Frameworks */, + EA2F9F962CF406E900B9F454 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EA2F9F9A2CF406E900B9F454 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + EA2F9F9B2CF406E900B9F454 /* RTSDAUITests */, + ); + name = RTSDAUITests; + packageProductDependencies = ( + ); + productName = RTSDAUITests; + productReference = EA2F9F982CF406E900B9F454 /* RTSDAUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + EA2F9F762CF406E800B9F454 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1610; + LastUpgradeCheck = 1620; + TargetAttributes = { + EA2F9F7D2CF406E800B9F454 = { + CreatedOnToolsVersion = 16.1; + }; + EA2F9F8D2CF406E900B9F454 = { + CreatedOnToolsVersion = 16.1; + TestTargetID = EA2F9F7D2CF406E800B9F454; + }; + EA2F9F972CF406E900B9F454 = { + CreatedOnToolsVersion = 16.1; + TestTargetID = EA2F9F7D2CF406E800B9F454; + }; + }; + }; + buildConfigurationList = EA2F9F792CF406E800B9F454 /* Build configuration list for PBXProject "RTSDA" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = EA2F9F752CF406E800B9F454; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + ); + preferredProjectObjectVersion = 77; + productRefGroup = EA2F9F7F2CF406E800B9F454 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + EA2F9F7D2CF406E800B9F454 /* RTSDA */, + EA2F9F8D2CF406E900B9F454 /* RTSDATests */, + EA2F9F972CF406E900B9F454 /* RTSDAUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + EA2F9F7C2CF406E800B9F454 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA2F9F8C2CF406E900B9F454 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA2F9F962CF406E900B9F454 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + EA2F9F7A2CF406E800B9F454 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA2F9F8A2CF406E900B9F454 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA2F9F942CF406E900B9F454 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + EA2F9F902CF406E900B9F454 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EA2F9F7D2CF406E800B9F454 /* RTSDA */; + targetProxy = EA2F9F8F2CF406E900B9F454 /* PBXContainerItemProxy */; + }; + EA2F9F9A2CF406E900B9F454 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EA2F9F7D2CF406E800B9F454 /* RTSDA */; + targetProxy = EA2F9F992CF406E900B9F454 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + EA2F9FA02CF406E900B9F454 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + NEW_SETTING = ""; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + EA2F9FA12CF406E900B9F454 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = "$(inherited)"; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + NEW_SETTING = ""; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + EA2F9FA32CF406E900B9F454 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RTSDA/RTSDA.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_ASSET_PATHS = "\"RTSDA/Preview Content\""; + DEVELOPMENT_TEAM = TQMND62F2W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RTSDA/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.reference"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + EA2F9FA42CF406E900B9F454 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = RTSDA/RTSDA.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_ASSET_PATHS = "\"RTSDA/Preview Content\""; + DEVELOPMENT_TEAM = TQMND62F2W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = RTSDA/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.reference"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + EA2F9FA62CF406E900B9F454 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = TQMND62F2W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.RTSDATests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RTSDA.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RTSDA"; + }; + name = Debug; + }; + EA2F9FA72CF406E900B9F454 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = TQMND62F2W; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.RTSDATests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/RTSDA.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/RTSDA"; + }; + name = Release; + }; + EA2F9FA92CF406E900B9F454 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = TQMND62F2W; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.RTSDAUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = RTSDA; + }; + name = Debug; + }; + EA2F9FAA2CF406E900B9F454 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = TQMND62F2W; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.RTSDAUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = RTSDA; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + EA2F9F792CF406E800B9F454 /* Build configuration list for PBXProject "RTSDA" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA2F9FA02CF406E900B9F454 /* Debug */, + EA2F9FA12CF406E900B9F454 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EA2F9FA22CF406E900B9F454 /* Build configuration list for PBXNativeTarget "RTSDA" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA2F9FA32CF406E900B9F454 /* Debug */, + EA2F9FA42CF406E900B9F454 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EA2F9FA52CF406E900B9F454 /* Build configuration list for PBXNativeTarget "RTSDATests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA2F9FA62CF406E900B9F454 /* Debug */, + EA2F9FA72CF406E900B9F454 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EA2F9FA82CF406E900B9F454 /* Build configuration list for PBXNativeTarget "RTSDAUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA2F9FA92CF406E900B9F454 /* Debug */, + EA2F9FAA2CF406E900B9F454 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = EA2F9F762CF406E800B9F454 /* Project object */; +} diff --git a/RTSDA.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/RTSDA.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/RTSDA.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/RTSDA.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/RTSDA.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/RTSDA.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/RTSDAApp.swift b/RTSDAApp.swift new file mode 100644 index 0000000..3ce5478 --- /dev/null +++ b/RTSDAApp.swift @@ -0,0 +1,19 @@ +// +// RTSDAApp.swift +// RTSDA +// +// Created by Benjamin Slingo on 11/24/24. +// + +import SwiftUI + +@main +struct RTSDAApp: App { + @StateObject private var configService = ConfigService.shared + + var body: some Scene { + WindowGroup { + SplashScreenView() + } + } +} diff --git a/Resources/Fonts/Lora-Italic.ttf b/Resources/Fonts/Lora-Italic.ttf new file mode 100644 index 0000000..d93bc5f Binary files /dev/null and b/Resources/Fonts/Lora-Italic.ttf differ diff --git a/Resources/Fonts/Lora-Regular.ttf b/Resources/Fonts/Lora-Regular.ttf new file mode 100644 index 0000000..2b1dab4 Binary files /dev/null and b/Resources/Fonts/Lora-Regular.ttf differ diff --git a/Resources/Fonts/Montserrat-Regular.ttf b/Resources/Fonts/Montserrat-Regular.ttf new file mode 100644 index 0000000..48ba65e Binary files /dev/null and b/Resources/Fonts/Montserrat-Regular.ttf differ diff --git a/Resources/Fonts/Montserrat-SemiBold.ttf b/Resources/Fonts/Montserrat-SemiBold.ttf new file mode 100644 index 0000000..8dbcdb3 Binary files /dev/null and b/Resources/Fonts/Montserrat-SemiBold.ttf differ diff --git a/Services/AppAvailabilityService.swift b/Services/AppAvailabilityService.swift new file mode 100644 index 0000000..b70f9b8 --- /dev/null +++ b/Services/AppAvailabilityService.swift @@ -0,0 +1,120 @@ +import Foundation +import UIKit + +class AppAvailabilityService { + static let shared = AppAvailabilityService() + + private var availabilityCache: [String: Bool] = [:] + + // Common URL schemes + struct Schemes { + static let bible = "youversion://" + static let egw = "egw-ios://" + static let hymnal = "adventisthymnarium://" + static let sabbathSchool = "com.googleusercontent.apps.443920152945-d0kf5h2dubt0jbcntq8l0qeg6lbpgn60://" + static let sabbathSchoolAlt = "https://sabbath-school.adventech.io" + static let egwWritingsWeb = "https://m.egwwritings.org/en/folders/2" + static let facebook = "https://www.facebook.com/rockvilletollandsdachurch/" + static let tiktok = "https://www.tiktok.com/@rockvilletollandsda" + static let spotify = "spotify://show/2ARQaUBaGnVTiF9syrKDvO" + static let podcasts = "podcasts://podcasts.apple.com/us/podcast/rockville-tolland-sda-church/id1630777684" + } + + // App Store fallback URLs + struct AppStoreURLs { + static let sabbathSchool = "https://apps.apple.com/us/app/sabbath-school/id895272167" + static let egwWritings = "https://apps.apple.com/us/app/egw-writings-2/id994076136" + static let egwWritingsWeb = "https://m.egwwritings.org/en/folders/2" + static let hymnal = "https://apps.apple.com/us/app/hymnal-adventist/id6446034427" + static let bible = "https://apps.apple.com/us/app/bible/id282935706" + static let facebook = "https://apps.apple.com/us/app/facebook/id284882215" + static let tiktok = "https://apps.apple.com/us/app/tiktok/id835599320" + static let spotify = "https://apps.apple.com/us/app/spotify-music-and-podcasts/id324684580" + static let podcasts = "https://apps.apple.com/us/app/apple-podcasts/id525463029" + } + + private init() { + // Check for common apps at launch + checkAvailability(urlScheme: Schemes.sabbathSchool) + checkAvailability(urlScheme: Schemes.egw) + checkAvailability(urlScheme: Schemes.bible) + checkAvailability(urlScheme: Schemes.facebook) + checkAvailability(urlScheme: Schemes.tiktok) + checkAvailability(urlScheme: Schemes.spotify) + checkAvailability(urlScheme: Schemes.podcasts) + } + + func isAppInstalled(urlScheme: String) -> Bool { + if let cached = availabilityCache[urlScheme] { + return cached + } + return checkAvailability(urlScheme: urlScheme) + } + + @discardableResult + private func checkAvailability(urlScheme: String) -> Bool { + guard let url = URL(string: urlScheme) else { + print("⚠️ Failed to create URL for scheme: \(urlScheme)") + return false + } + let isAvailable = UIApplication.shared.canOpenURL(url) + print("📱 App availability for \(urlScheme): \(isAvailable)") + availabilityCache[urlScheme] = isAvailable + return isAvailable + } + + func openApp(urlScheme: String, fallbackURL: String) { + // First try the URL scheme + if let appUrl = URL(string: urlScheme) { + print("🔗 Attempting to open URL: \(appUrl)") + + // Check if we can open the URL + if UIApplication.shared.canOpenURL(appUrl) { + print("✅ Opening app URL: \(appUrl)") + UIApplication.shared.open(appUrl) { success in + if !success { + print("❌ Failed to open app URL: \(appUrl)") + self.handleFallback(urlScheme: urlScheme, fallbackURL: fallbackURL) + } + } + } else { + print("❌ Cannot open app URL: \(appUrl)") + handleFallback(urlScheme: urlScheme, fallbackURL: fallbackURL) + } + } else { + print("⚠️ Failed to create URL: \(urlScheme)") + handleFallback(urlScheme: urlScheme, fallbackURL: fallbackURL) + } + } + + private func handleFallback(urlScheme: String, fallbackURL: String) { + // Special handling for Sabbath School app + if urlScheme == Schemes.sabbathSchool { + if let altUrl = URL(string: Schemes.sabbathSchoolAlt) { + print("✅ Opening Sabbath School web app: \(altUrl)") + UIApplication.shared.open(altUrl) + return + } + } + // Special handling for EGW Writings app + else if urlScheme == Schemes.egw { + if let webUrl = URL(string: Schemes.egwWritingsWeb) { + print("✅ Opening EGW mobile web URL: \(webUrl)") + UIApplication.shared.open(webUrl) + return + } + } + + // Try the fallback URL + if let fallback = URL(string: fallbackURL) { + print("⬇️ Falling back to: \(fallback)") + UIApplication.shared.open(fallback) { success in + if !success { + print("❌ Failed to open fallback URL: \(fallback)") + } + } + } else { + print("❌ Failed to create fallback URL: \(fallbackURL)") + } + } +} diff --git a/Services/BibleService.swift b/Services/BibleService.swift new file mode 100644 index 0000000..0a95836 --- /dev/null +++ b/Services/BibleService.swift @@ -0,0 +1,71 @@ +import Foundation + +@MainActor +class BibleService { + static let shared = BibleService() + private let configService = ConfigService.shared + private let baseURL = "https://api.scripture.api.bible/v1" + + private init() {} + + // API Response structures + struct BibleAPIResponse: Codable { + let data: VerseData + } + + struct VerseData: Codable { + let id: String + let orgId: String + let bibleId: String + let bookId: String + let chapterId: String + let reference: String + let content: String + + // The API returns HTML content, so we'll clean it up + var cleanContent: String { + return content + .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + .replacingOccurrences(of: """, with: "\"") + .replacingOccurrences(of: "'", with: "'") + .replacingOccurrences(of: "NUN\\s+\\d+\\s+", with: "", options: .regularExpression) // Remove Hebrew letter prefixes + .replacingOccurrences(of: "^[A-Z]+\\s+\\d+\\s+", with: "", options: .regularExpression) // Remove any other letter prefixes + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + func getRandomVerse() async throws -> (verse: String, reference: String) { + // List of popular and uplifting Bible verses + let references = [ + "JER.29.11", "PRO.3.5", "PHP.4.13", "JOS.1.9", "PSA.23.1", + "ISA.40.31", "MAT.11.28", "ROM.8.28", "PSA.27.1", "PSA.46.10", + "JHN.3.16", "ROM.15.13", "2CO.5.7", "DEU.31.6", "ROM.8.31", + "1JN.4.19", "PHP.4.6", "MAT.6.33", "HEB.11.1", "PSA.37.4" + ] + + // Randomly select a reference + let randomReference = references.randomElement() ?? "JHN.3.16" + + guard let apiKey = configService.bibleApiKey else { + throw NSError(domain: "BibleService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bible API key not found"]) + } + + // Construct the API URL + let urlString = "\(baseURL)/bibles/de4e12af7f28f599-01/verses/\(randomReference)" + var request = URLRequest(url: URL(string: urlString)!) + request.addValue(apiKey, forHTTPHeaderField: "api-key") + + let (data, response) = try await URLSession.shared.data(for: request) + + // Check for successful response + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw NSError(domain: "BibleService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch verse"]) + } + + let decoder = JSONDecoder() + let apiResponse = try decoder.decode(BibleAPIResponse.self, from: data) + + return (verse: apiResponse.data.cleanContent, reference: apiResponse.data.reference) + } +} diff --git a/Services/ConfigService.swift b/Services/ConfigService.swift new file mode 100644 index 0000000..9069d69 --- /dev/null +++ b/Services/ConfigService.swift @@ -0,0 +1,63 @@ +import Foundation + +@MainActor +class ConfigService: ObservableObject { + static let shared = ConfigService() + private let pocketBaseService = PocketBaseService.shared + + @Published private(set) var config: Config? + @Published private(set) var error: Error? + @Published private(set) var isLoading = false + + private init() {} + + var bibleApiKey: String? { + config?.apiKeys.bibleApiKey + } + + var jellyfinApiKey: String? { + config?.apiKeys.jellyfinApiKey + } + + var churchName: String { + config?.churchName ?? "Rockville-Tolland SDA Church" + } + + var aboutText: String { + config?.aboutText ?? "" + } + + var contactEmail: String { + config?.contactEmail ?? "av@rockvilletollandsda.org" + } + + var contactPhone: String { + config?.contactPhone ?? "8608750450" + } + + var churchAddress: String { + config?.churchAddress ?? "9 Hartford Tpke Tolland CT 06084" + } + + var googleMapsUrl: String { + config?.googleMapsUrl ?? "https://maps.app.goo.gl/Ld4YZFPQGxGRBFJt8" + } + + func loadConfig() async { + isLoading = true + error = nil + + do { + config = try await pocketBaseService.fetchConfig() + } catch { + self.error = error + print("Failed to load app configuration: \(error)") + } + + isLoading = false + } + + func refreshConfig() async { + await loadConfig() + } +} \ No newline at end of file diff --git a/Services/ImageCache.swift b/Services/ImageCache.swift new file mode 100644 index 0000000..e280cd3 --- /dev/null +++ b/Services/ImageCache.swift @@ -0,0 +1,23 @@ +import SwiftUI + +actor ImageCache { + static let shared = ImageCache() + + private let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 100 // Maximum number of images to cache + return cache + }() + + private init() {} + + func image(for url: URL) -> UIImage? { + let key = url.absoluteString as NSString + return cache.object(forKey: key) + } + + func setImage(_ image: UIImage, for url: URL) { + let key = url.absoluteString as NSString + cache.setObject(image, forKey: key) + } +} \ No newline at end of file diff --git a/Services/JellyfinService.swift b/Services/JellyfinService.swift new file mode 100644 index 0000000..9577451 --- /dev/null +++ b/Services/JellyfinService.swift @@ -0,0 +1,280 @@ +import Foundation + +@MainActor +class JellyfinService { + static let shared = JellyfinService() + private let configService = ConfigService.shared + + private let baseUrl = "https://jellyfin.rockvilletollandsda.church" + private var apiKey: String? { + configService.jellyfinApiKey + } + private var libraryId: String? + private var currentType: MediaType = .sermons + + enum MediaType: String { + case sermons = "Sermons" + case livestreams = "LiveStreams" + } + + enum JellyfinError: Error { + case invalidURL + case networkError + case decodingError + case noVideosFound + case libraryNotFound + } + + struct JellyfinItem: Codable { + let id: String + let name: String + let tags: [String]? + let premiereDate: String? + let productionYear: Int? + let overview: String? + let mediaType: String + let type: String + let path: String + let dateCreated: String + + enum CodingKeys: String, CodingKey { + case id = "Id" + case name = "Name" + case tags = "Tags" + case premiereDate = "PremiereDate" + case productionYear = "ProductionYear" + case overview = "Overview" + case mediaType = "MediaType" + case type = "Type" + case path = "Path" + case dateCreated = "DateCreated" + } + } + + private struct LibraryResponse: Codable { + let items: [Library] + + enum CodingKeys: String, CodingKey { + case items = "Items" + } + } + + private struct Library: Codable { + let id: String + let name: String + let path: String + + enum CodingKeys: String, CodingKey { + case id = "Id" + case name = "Name" + case path = "Path" + } + } + + private struct ItemsResponse: Codable { + let items: [JellyfinItem] + + enum CodingKeys: String, CodingKey { + case items = "Items" + } + } + + private init() {} + + func setType(_ type: MediaType) { + self.currentType = type + self.libraryId = nil // Reset library ID when switching types + } + + private func fetchWithAuth(_ url: URL) async throws -> (Data, URLResponse) { + // Ensure config is loaded + if configService.config == nil { + await configService.loadConfig() + } + + guard let apiKey = self.apiKey else { + throw JellyfinError.networkError + } + + var request = URLRequest(url: url) + request.timeoutInterval = 30 + request.addValue(apiKey, forHTTPHeaderField: "X-MediaBrowser-Token") + request.addValue("MediaBrowser Client=\"RTSDA iOS\", Device=\"iOS\", DeviceId=\"rtsda-ios\", Version=\"1.0.0\", Token=\"\(apiKey)\"", + forHTTPHeaderField: "X-Emby-Authorization") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + // Check if task was cancelled + try Task.checkCancellation() + + guard let httpResponse = response as? HTTPURLResponse else { + throw JellyfinError.networkError + } + + guard (200...299).contains(httpResponse.statusCode) else { + if let responseString = String(data: data, encoding: .utf8) { + print("Jellyfin API error: \(responseString)") + } + throw JellyfinError.networkError + } + + return (data, response) + } catch is CancellationError { + throw URLError(.cancelled) + } catch { + print("Network request failed: \(error.localizedDescription)") + throw error + } + } + + private func getLibraryId() async throws -> String { + if let id = libraryId { return id } + + let url = URL(string: "\(baseUrl)/Library/MediaFolders")! + + do { + let (data, _) = try await fetchWithAuth(url) + + // Check if task was cancelled + try Task.checkCancellation() + + let response = try JSONDecoder().decode(LibraryResponse.self, from: data) + + let searchTerm = currentType == .sermons ? "Sermons" : "LiveStreams" + + // Try exact match on name + if let library = response.items.first(where: { $0.name == searchTerm }) { + libraryId = library.id + return library.id + } + + print("Library not found: \(searchTerm)") + print("Available libraries: \(response.items.map { $0.name }.joined(separator: ", "))") + throw JellyfinError.libraryNotFound + } catch is CancellationError { + throw URLError(.cancelled) + } catch { + print("Error fetching library ID: \(error.localizedDescription)") + throw error + } + } + + private func parseDate(_ dateString: String) -> Date? { + // Create formatters for different possible formats + let formatters = [ + { () -> DateFormatter in + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" // PocketBase format + return formatter + }(), + { () -> ISO8601DateFormatter in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }(), + { () -> ISO8601DateFormatter in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + ] + + for formatter in formatters { + if let date = (formatter as? ISO8601DateFormatter)?.date(from: dateString) ?? + (formatter as? DateFormatter)?.date(from: dateString) { + return date + } + } + + return nil + } + + func fetchSermons(type: SermonType) async throws -> [Sermon] { + currentType = MediaType(rawValue: type.rawValue)! + let libraryId = try await getLibraryId() + + // Check if task was cancelled + try Task.checkCancellation() + + let urlString = "\(baseUrl)/Items?ParentId=\(libraryId)&Fields=Path,PremiereDate,ProductionYear,Overview,DateCreated&Recursive=true&IncludeItemTypes=Movie,Video,Episode&SortBy=DateCreated&SortOrder=Descending" + guard let url = URL(string: urlString) else { + throw JellyfinError.invalidURL + } + + let (data, _) = try await fetchWithAuth(url) + + // Check if task was cancelled + try Task.checkCancellation() + + let response = try JSONDecoder().decode(ItemsResponse.self, from: data) + + return response.items.map { item in + var title = item.name + var speaker = "Unknown Speaker" + + // Remove file extension if present + title = title.replacingOccurrences(of: #"\.(mp4|mov)$"#, with: "", options: .regularExpression) + + // Try to split into title and speaker + let parts = title.components(separatedBy: " - ") + if parts.count > 1 { + title = parts[0].trimmingCharacters(in: .whitespaces) + let speakerPart = parts[1].trimmingCharacters(in: .whitespaces) + speaker = speakerPart.replacingOccurrences( + of: #"\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+(?:th|st|nd|rd)?\s*\d{4}$"#, + with: "", + options: .regularExpression + ).replacingOccurrences(of: "|", with: "").trimmingCharacters(in: .whitespaces) + } + + // Parse date with UTC handling + let rawDate = item.premiereDate ?? item.dateCreated + let utcDate = parseDate(rawDate) ?? Date() + + // Extract components in UTC and create a new date at noon UTC to avoid timezone issues + var calendar = Calendar.current + calendar.timeZone = TimeZone(identifier: "UTC")! + var components = calendar.dateComponents([.year, .month, .day], from: utcDate) + components.hour = 12 // Set to noon UTC to ensure date remains the same in all timezones + let localDate = calendar.date(from: components) ?? Date() + + return Sermon( + id: item.id, + title: title, + description: item.overview ?? "", + date: localDate, + speaker: speaker, + type: type, + videoUrl: getStreamUrl(itemId: item.id), + thumbnail: getImageUrl(itemId: item.id) + ) + } + } + + private func getStreamUrl(itemId: String) -> String { + var components = URLComponents(string: "\(baseUrl)/Videos/\(itemId)/master.m3u8")! + guard let apiKey = self.apiKey else { return "" } + components.queryItems = [ + URLQueryItem(name: "api_key", value: apiKey), + URLQueryItem(name: "MediaSourceId", value: itemId), + URLQueryItem(name: "TranscodingProtocol", value: "hls"), + URLQueryItem(name: "RequireAvc", value: "true"), + URLQueryItem(name: "MaxStreamingBitrate", value: "20000000"), + URLQueryItem(name: "VideoBitrate", value: "10000000"), + URLQueryItem(name: "AudioBitrate", value: "192000"), + URLQueryItem(name: "AudioCodec", value: "aac"), + URLQueryItem(name: "VideoCodec", value: "h264"), + URLQueryItem(name: "MaxAudioChannels", value: "2"), + URLQueryItem(name: "StartTimeTicks", value: "0"), + URLQueryItem(name: "SubtitleMethod", value: "Embed"), + URLQueryItem(name: "TranscodeReasons", value: "VideoCodecNotSupported") + ] + return components.url!.absoluteString + } + + private func getImageUrl(itemId: String) -> String { + guard let apiKey = self.apiKey else { return "" } + return "\(baseUrl)/Items/\(itemId)/Images/Primary?api_key=\(apiKey)" + } +} \ No newline at end of file diff --git a/Services/OwnCastService.swift b/Services/OwnCastService.swift new file mode 100644 index 0000000..402026c --- /dev/null +++ b/Services/OwnCastService.swift @@ -0,0 +1,78 @@ +import Foundation + +class OwnCastService { + static let shared = OwnCastService() + + private let baseUrl = "https://stream.rockvilletollandsda.church" + + private init() {} + + struct StreamStatus: Codable { + let online: Bool + let streamTitle: String? + let lastConnectTime: String? + let lastDisconnectTime: String? + let serverTime: String? + let versionNumber: String? + + enum CodingKeys: String, CodingKey { + case online + case streamTitle = "name" + case lastConnectTime + case lastDisconnectTime + case serverTime + case versionNumber + } + } + + func getStreamStatus() async throws -> StreamStatus { + guard let url = URL(string: "\(baseUrl)/api/status") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + guard (200...299).contains(httpResponse.statusCode) else { + if let responseString = String(data: data, encoding: .utf8) { + print("Stream status error: \(responseString)") + } + throw URLError(.badServerResponse) + } + + do { + return try JSONDecoder().decode(StreamStatus.self, from: data) + } catch { + print("Failed to decode stream status: \(error)") + throw error + } + } + + func createLivestreamMessage(from status: StreamStatus) -> Message { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.timeZone = TimeZone(identifier: "America/New_York") + let formattedDate = dateFormatter.string(from: Date()) + + return Message( + id: UUID().uuidString, + title: status.streamTitle ?? "Live Stream", + description: "Watch our live stream", + speaker: "Live Stream", + videoUrl: "\(baseUrl)/hls/stream.m3u8", + thumbnailUrl: "\(baseUrl)/thumbnail.jpg", + duration: 0, + isLiveStream: true, + isPublished: true, + isDeleted: false, + liveBroadcastStatus: "live", + date: formattedDate + ) + } +} \ No newline at end of file diff --git a/Services/PocketBaseService.swift b/Services/PocketBaseService.swift new file mode 100644 index 0000000..fd3a902 --- /dev/null +++ b/Services/PocketBaseService.swift @@ -0,0 +1,119 @@ +import Foundation + +class PocketBaseService { + static let shared = PocketBaseService() + private let baseURL = "https://pocketbase.rockvilletollandsda.church" + + private init() {} + + struct EventResponse: Codable { + let page: Int + let perPage: Int + let totalItems: Int + let totalPages: Int + let items: [Event] + + enum CodingKeys: String, CodingKey { + case page + case perPage + case totalItems + case totalPages + case items + } + } + + @MainActor + func fetchConfig() async throws -> Config { + let recordId = "nn753t8o2t1iupd" + let urlString = "\(baseURL)/api/collections/config/records/\(recordId)" + + guard let url = URL(string: urlString) else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + guard httpResponse.statusCode == 200 else { + if let errorString = String(data: data, encoding: .utf8) { + print("Error fetching config: \(errorString)") + } + throw URLError(.badServerResponse) + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + do { + return try decoder.decode(Config.self, from: data) + } catch { + print("Failed to decode config: \(error)") + if let decodingError = error as? DecodingError { + switch decodingError { + case .keyNotFound(let key, let context): + print("Missing key: \(key.stringValue) in \(context.debugDescription)") + case .typeMismatch(let type, let context): + print("Type mismatch: expected \(type) in \(context.debugDescription)") + case .valueNotFound(let type, let context): + print("Value not found: expected \(type) in \(context.debugDescription)") + case .dataCorrupted(let context): + print("Data corrupted: \(context.debugDescription)") + @unknown default: + print("Unknown decoding error") + } + } + throw error + } + } + + @MainActor + func fetchEvents() async throws -> [Event] { + guard let url = URL(string: "\(baseURL)/api/collections/events/records?sort=start_time") else { + throw URLError(.badURL) + } + + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + guard httpResponse.statusCode == 200 else { + if let errorString = String(data: data, encoding: .utf8) { + print("Error fetching events: \(errorString)") + } + throw URLError(.badServerResponse) + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + do { + let eventResponse = try decoder.decode(EventResponse.self, from: data) + return eventResponse.items + } catch { + print("Failed to decode events: \(error)") + if let decodingError = error as? DecodingError { + switch decodingError { + case .keyNotFound(let key, let context): + print("Missing key: \(key.stringValue) in \(context.debugDescription)") + case .typeMismatch(let type, let context): + print("Type mismatch: expected \(type) in \(context.debugDescription)") + case .valueNotFound(let type, let context): + print("Value not found: expected \(type) in \(context.debugDescription)") + case .dataCorrupted(let context): + print("Data corrupted: \(context.debugDescription)") + @unknown default: + print("Unknown decoding error") + } + } + throw error + } + } +} \ No newline at end of file diff --git a/ViewModels/EventsViewModel.swift b/ViewModels/EventsViewModel.swift new file mode 100644 index 0000000..96f5a3b --- /dev/null +++ b/ViewModels/EventsViewModel.swift @@ -0,0 +1,40 @@ +import SwiftUI + +@MainActor +class EventsViewModel: ObservableObject { + @Published private(set) var events: [Event] = [] + @Published private(set) var isLoading = false + @Published private(set) var error: Error? + + private let pocketBaseService = PocketBaseService.shared + + func loadEvents() async { + isLoading = true + error = nil + + do { + let now = Date() + let calendar = Calendar.current + let todayStart = calendar.startOfDay(for: now) + + // Keep events that either: + // 1. Start in the future (after today), or + // 2. Are today and haven't ended yet + events = try await pocketBaseService.fetchEvents() + .filter { event in + let eventStart = calendar.startOfDay(for: event.startDate) + if eventStart > todayStart { + return true // Future event + } else if eventStart == todayStart { + return event.endDate > now // Today's event that hasn't ended + } + return false + } + .sorted { $0.startDate < $1.startDate } + } catch { + self.error = error + } + + isLoading = false + } +} diff --git a/ViewModels/MessagesViewModel.swift b/ViewModels/MessagesViewModel.swift new file mode 100644 index 0000000..a4914d1 --- /dev/null +++ b/ViewModels/MessagesViewModel.swift @@ -0,0 +1,249 @@ +import Foundation + +@MainActor +class MessagesViewModel: ObservableObject { + @Published var messages: [Message] = [] + @Published var filteredMessages: [Message] = [] + @Published var livestream: Message? + @Published var isLoading = false + @Published var error: Error? + @Published var availableYears: [String] = [] + @Published var availableMonths: [String] = [] + @Published var currentMediaType: JellyfinService.MediaType = .sermons + + private let jellyfinService = JellyfinService.shared + private let owncastService = OwnCastService.shared + private var currentTask: Task? + private var autoRefreshTask: Task? + + init() { + Task { + await loadContent() + startAutoRefresh() + } + } + + deinit { + autoRefreshTask?.cancel() + } + + private func startAutoRefresh() { + // Cancel any existing auto-refresh task + autoRefreshTask?.cancel() + + // Create new auto-refresh task + autoRefreshTask = Task { + while !Task.isCancelled { + // Wait for 5 seconds + try? await Task.sleep(nanoseconds: 5 * 1_000_000_000) + + // Check only livestream status + let streamStatus = try? await owncastService.getStreamStatus() + print("📺 Stream status: \(String(describing: streamStatus))") + if let status = streamStatus, status.online { + print("📺 Stream is online! Creating livestream message") + self.livestream = owncastService.createLivestreamMessage(from: status) + print("📺 Livestream message created: \(String(describing: self.livestream))") + } else { + print("📺 Stream is offline or status check failed") + self.livestream = nil + } + } + } + } + + func loadContent(mediaType: JellyfinService.MediaType = .sermons) async { + currentMediaType = mediaType + guard !isLoading else { return } + isLoading = true + error = nil + + // Cancel any existing task + currentTask?.cancel() + + // Create a new task for content loading + currentTask = Task { + do { + // Check OwnCast stream status + if !Task.isCancelled { + let streamStatus = try? await owncastService.getStreamStatus() + print("📺 Initial stream status: \(String(describing: streamStatus))") + if let status = streamStatus, status.online { + print("📺 Stream is online on initial load! Creating livestream message") + self.livestream = owncastService.createLivestreamMessage(from: status) + print("📺 Initial livestream message created: \(String(describing: self.livestream))") + } else { + print("📺 Stream is offline on initial load") + self.livestream = nil + } + } + + // Set media type and fetch content + if !Task.isCancelled { + jellyfinService.setType(mediaType) + let sermons = try await jellyfinService.fetchSermons(type: mediaType == .sermons ? .sermon : .liveArchive) + + // Create simple date formatter + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(identifier: "America/New_York") + + // Convert sermons to messages + self.messages = sermons.map { sermon in + Message( + id: sermon.id, + title: sermon.title, + description: sermon.description, + speaker: sermon.speaker, + videoUrl: sermon.videoUrl ?? "", + thumbnailUrl: sermon.thumbnail ?? "", + duration: 0, + isLiveStream: sermon.type == .liveArchive, + isPublished: true, + isDeleted: false, + liveBroadcastStatus: sermon.type == .liveArchive ? "live" : "none", + date: formatter.string(from: sermon.date) + ) + } + .sorted { $0.date > $1.date } + + // Update available years and months + updateAvailableFilters() + + // Initialize filtered messages with all messages + self.filteredMessages = self.messages + + // Only show error if both content and livestream failed + if self.messages.isEmpty && self.livestream == nil { + self.error = JellyfinService.JellyfinError.noVideosFound + } + } + } catch { + if !Task.isCancelled { + self.error = error + print("Error loading content: \(error.localizedDescription)") + } + } + + if !Task.isCancelled { + isLoading = false + } + } + + // Wait for the task to complete + await currentTask?.value + } + + func refreshContent() async { + // Check stream status first + let streamStatus = try? await owncastService.getStreamStatus() + if let status = streamStatus, status.online { + self.livestream = owncastService.createLivestreamMessage(from: status) + } else { + self.livestream = nil + } + + // Then load the rest of the content + await loadContent(mediaType: currentMediaType) + } + + private func updateAvailableFilters() { + // Get messages for current media type + let currentMessages = messages.filter { message in + message.isLiveStream == (currentMediaType == .livestreams) + } + + // Create date formatter + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + + // Get unique years first + var years = Set() + var monthsByYear: [String: Set] = [:] + + for message in currentMessages { + if let date = formatter.date(from: message.date) { + let calendar = Calendar.current + let year = String(calendar.component(.year, from: date)) + let month = String(format: "%02d", calendar.component(.month, from: date)) + + years.insert(year) + + // Group months by year + if monthsByYear[year] == nil { + monthsByYear[year] = Set() + } + monthsByYear[year]?.insert(month) + } + } + + // Sort years descending (newest first) + availableYears = Array(years).sorted(by: >) + + // Get months only for selected year (first year by default) + if let selectedYear = availableYears.first, + let monthsForYear = monthsByYear[selectedYear] { + availableMonths = Array(monthsForYear).sorted() + } else { + availableMonths = [] + } + } + + // Add a method to update months when year changes + func updateMonthsForYear(_ year: String) { + // Get messages for current media type and year + let currentMessages = messages.filter { message in + message.isLiveStream == (currentMediaType == .livestreams) && + message.date.hasPrefix(year) + } + + // Create date formatter + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + + // Get months for the selected year + var months = Set() + + for message in currentMessages { + if let date = formatter.date(from: message.date) { + let calendar = Calendar.current + let month = String(format: "%02d", calendar.component(.month, from: date)) + months.insert(month) + } + } + + // Sort months ascending (Jan to Dec) + availableMonths = Array(months).sorted() + } + + func filterContent(year: String? = nil, month: String? = nil) { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + + // Filter by type first + let typeFiltered = messages.filter { message in + message.isLiveStream == (currentMediaType == .livestreams) + } + + // Then filter by date components + let dateFiltered = typeFiltered.filter { message in + guard let date = formatter.date(from: message.date) else { return false } + let calendar = Calendar.current + + if let year = year { + let messageYear = String(calendar.component(.year, from: date)) + if messageYear != year { return false } + } + + if let month = month { + let messageMonth = String(format: "%02d", calendar.component(.month, from: date)) + if messageMonth != month { return false } + } + + return true + } + + // Sort by date, newest first + filteredMessages = dateFiltered.sorted { $0.date > $1.date } + } +} diff --git a/ViewModels/OwncastViewModel.swift b/ViewModels/OwncastViewModel.swift new file mode 100644 index 0000000..dd02421 --- /dev/null +++ b/ViewModels/OwncastViewModel.swift @@ -0,0 +1,26 @@ +import Foundation + +@MainActor +class OwncastViewModel: ObservableObject { + @Published var streamUrl: URL? + @Published var isLoading = false + private let owncastService = OwnCastService.shared + private let baseUrl = "https://stream.rockvilletollandsda.church" + + func checkStreamStatus() async { + isLoading = true + defer { isLoading = false } + + do { + let status = try await owncastService.getStreamStatus() + if status.online { + streamUrl = URL(string: "\(baseUrl)/hls/stream.m3u8") + } else { + streamUrl = nil + } + } catch { + print("Failed to check stream status:", error) + streamUrl = nil + } + } +} \ No newline at end of file diff --git a/ViewModels/SermonBrowserViewModel.swift b/ViewModels/SermonBrowserViewModel.swift new file mode 100644 index 0000000..8849809 --- /dev/null +++ b/ViewModels/SermonBrowserViewModel.swift @@ -0,0 +1,148 @@ +import Foundation +import AVKit + +@MainActor +class SermonBrowserViewModel: ObservableObject { + @Published private(set) var sermons: [Sermon] = [] + @Published private(set) var isLoading = false + @Published private(set) var error: Error? + @Published var selectedType: SermonType = .sermon + @Published var selectedYear: Int? + @Published var selectedMonth: Int? + + let jellyfinService: JellyfinService + + var organizedSermons: [Int: [Int: [Sermon]]] { + var filteredSermons = sermons.filter { $0.type == selectedType } + + // Apply year filter if selected + if let selectedYear = selectedYear { + filteredSermons = filteredSermons.filter { + Calendar.current.component(.year, from: $0.date) == selectedYear + } + } + + // Apply month filter if selected + if let selectedMonth = selectedMonth { + filteredSermons = filteredSermons.filter { + Calendar.current.component(.month, from: $0.date) == selectedMonth + } + } + + return Dictionary(grouping: filteredSermons) { sermon in + Calendar.current.component(.year, from: sermon.date) + }.mapValues { yearSermons in + Dictionary(grouping: yearSermons) { sermon in + Calendar.current.component(.month, from: sermon.date) + } + } + } + + var years: [Int] { + let filteredSermons = sermons.filter { $0.type == selectedType } + let allYears = Set(filteredSermons.map { + Calendar.current.component(.year, from: $0.date) + }) + return Array(allYears).sorted(by: >) + } + + func months(for year: Int) -> [Int] { + let yearSermons = sermons.filter { + $0.type == selectedType && + Calendar.current.component(.year, from: $0.date) == year + } + return Array(Set(yearSermons.map { + Calendar.current.component(.month, from: $0.date) + })).sorted(by: >) + } + + func sermons(for year: Int, month: Int) -> [Sermon] { + organizedSermons[year]?[month]?.sorted(by: { $0.date > $1.date }) ?? [] + } + + @MainActor + init(jellyfinService: JellyfinService) { + self.jellyfinService = jellyfinService + } + + @MainActor + convenience init() { + self.init(jellyfinService: JellyfinService.shared) + } + + @MainActor + func fetchSermons() async throws { + isLoading = true + error = nil + + do { + sermons = try await jellyfinService.fetchSermons(type: .sermon) + + if let firstYear = years.first { + selectedYear = firstYear + if let firstMonth = months(for: firstYear).first { + selectedMonth = firstMonth + } + } + } catch { + self.error = error + throw error + } + + isLoading = false + } + + func selectType(_ type: SermonType) { + selectedType = type + selectedYear = nil + selectedMonth = nil + + if let firstYear = years.first { + selectedYear = firstYear + if let firstMonth = months(for: firstYear).first { + selectedMonth = firstMonth + } + } + } + + func selectYear(_ year: Int?) { + selectedYear = year + selectedMonth = nil + + if let year = year, + let firstMonth = months(for: year).first { + selectedMonth = firstMonth + } + } + + func selectMonth(_ month: Int?) { + selectedMonth = month + } + + @MainActor + func loadSermons() async { + isLoading = true + error = nil + + do { + sermons = try await jellyfinService.fetchSermons(type: .sermon) + + if let firstYear = years.first { + selectedYear = firstYear + if let firstMonth = months(for: firstYear).first { + selectedMonth = firstMonth + } + } + } catch { + self.error = error + } + + isLoading = false + } + + @MainActor + func requestPermissions() async { + let permissionsManager = PermissionsManager.shared + permissionsManager.requestLocationAccess() + } +} diff --git a/Views/BeliefsView.swift b/Views/BeliefsView.swift new file mode 100644 index 0000000..acbae35 --- /dev/null +++ b/Views/BeliefsView.swift @@ -0,0 +1,259 @@ +import SwiftUI + +struct Belief: Identifiable { + let id: Int + let title: String + let summary: String + let verses: [String] +} + +struct BeliefsView: View { + let beliefs = [ + Belief(id: 1, title: "The Holy Scriptures", + summary: "The Holy Scriptures, Old and New Testaments, are the written Word of God, given by divine inspiration. The inspired authors spoke and wrote as they were moved by the Holy Spirit.", + verses: ["2 Timothy 3:16-17", "2 Peter 1:20-21", "Psalm 119:105"]), + + Belief(id: 2, title: "The Trinity", + summary: "There is one God: Father, Son, and Holy Spirit, a unity of three coeternal Persons.", + verses: ["Deuteronomy 6:4", "Matthew 28:19", "2 Corinthians 13:14"]), + + Belief(id: 3, title: "The Father", + summary: "God the eternal Father is the Creator, Source, Sustainer, and Sovereign of all creation.", + verses: ["Genesis 1:1", "Revelation 4:11", "1 Corinthians 15:28"]), + + Belief(id: 4, title: "The Son", + summary: "God the eternal Son became incarnate in Jesus Christ. Through Him all things were created, the character of God is revealed, the salvation of humanity is accomplished, and the world is judged.", + verses: ["John 1:1-3", "John 1:14", "Colossians 1:15-19"]), + + Belief(id: 5, title: "The Holy Spirit", + summary: "God the eternal Spirit was active with the Father and the Son in Creation, incarnation, and redemption.", + verses: ["Genesis 1:1-2", "John 14:16-18", "John 16:7-13"]), + + Belief(id: 6, title: "Creation", + summary: "God has revealed in Scripture the authentic and historical account of His creative activity. He created the universe, and in a recent six-day creation the Lord made 'the heavens and the earth, the sea, and all that is in them' and rested on the seventh day.", + verses: ["Genesis 1-2", "Exodus 20:8-11", "Psalm 19:1-6"]), + + Belief(id: 7, title: "The Nature of Humanity", + summary: "Man and woman were made in the image of God with individuality, the power and freedom to think and to do.", + verses: ["Genesis 1:26-28", "Psalm 8:4-8", "Acts 17:24-28"]), + + Belief(id: 8, title: "The Great Controversy", + summary: "All humanity is now involved in a great controversy between Christ and Satan regarding the character of God, His law, and His sovereignty over the universe.", + verses: ["Revelation 12:4-9", "Isaiah 14:12-14", "Ezekiel 28:12-18"]), + + Belief(id: 9, title: "The Life, Death, and Resurrection of Christ", + summary: "In Christ's life of perfect obedience to God's will, His suffering, death, and resurrection, God provided the only means of atonement for human sin.", + verses: ["John 3:16", "Isaiah 53", "1 Peter 2:21-22"]), + + Belief(id: 10, title: "The Experience of Salvation", + summary: "In infinite love and mercy God made Christ, who knew no sin, to be sin for us, so that in Him we might be made the righteousness of God.", + verses: ["2 Corinthians 5:17-21", "John 3:16", "Galatians 1:4"]), + + Belief(id: 11, title: "Growing in Christ", + summary: "By His death on the cross Jesus triumphed over the forces of evil. He who subjugated the demonic spirits during His earthly ministry has broken their power and made certain their ultimate doom.", + verses: ["Philippians 2:5-8", "2 Corinthians 3:18", "1 Peter 1:23"]), + + Belief(id: 12, title: "The Church", + summary: "The church is the community of believers who confess Jesus Christ as Lord and Savior. In continuity with the people of God in Old Testament times, we are called out from the world.", + verses: ["Genesis 12:3", "Acts 7:38", "Ephesians 4:11-15"]), + + Belief(id: 13, title: "The Remnant and Its Mission", + summary: "The universal church is composed of all who truly believe in Christ, but in the last days, a time of widespread apostasy, a remnant has been called out to keep the commandments of God and the faith of Jesus.", + verses: ["Revelation 12:17", "Revelation 14:6-12", "2 Corinthians 5:10"]), + + Belief(id: 14, title: "Unity in the Body of Christ", + summary: "The church is one body with many members, called from every nation, kindred, tongue, and people. In Christ we are a new creation.", + verses: ["Psalm 133:1", "1 Corinthians 12:12-14", "Ephesians 4:4-6"]), + + Belief(id: 15, title: "Baptism", + summary: "By baptism we confess our faith in the death and resurrection of Jesus Christ, and testify of our death to sin and of our purpose to walk in newness of life.", + verses: ["Romans 6:1-6", "Colossians 2:12-13", "Acts 16:30-33"]), + + Belief(id: 16, title: "The Lord's Supper", + summary: "The Lord's Supper is a participation in the emblems of the body and blood of Jesus as an expression of faith in Him, our Lord and Savior.", + verses: ["1 Corinthians 10:16-17", "1 Corinthians 11:23-30", "Matthew 26:17-30"]), + + Belief(id: 17, title: "Spiritual Gifts and Ministries", + summary: "God bestows upon all members of His church spiritual gifts which each member is to employ in loving ministry for the common good of the church and humanity.", + verses: ["Romans 12:4-8", "1 Corinthians 12:9-11", "Ephesians 4:8"]), + + Belief(id: 18, title: "The Gift of Prophecy", + summary: "The Scriptures testify that one of the gifts of the Holy Spirit is prophecy. This gift is an identifying mark of the remnant church and we believe it was manifested in the ministry of Ellen G. White.", + verses: ["Joel 2:28-29", "Acts 2:14-21", "Revelation 12:17"]), + + Belief(id: 19, title: "The Law of God", + summary: "The great principles of God's law are embodied in the Ten Commandments and exemplified in the life of Christ. They express God's love, will, and purposes.", + verses: ["Exodus 20:1-17", "Psalm 40:7-8", "Matthew 22:36-40"]), + + Belief(id: 20, title: "The Sabbath", + summary: "The gracious Creator, after the six days of Creation, rested on the seventh day and instituted the Sabbath for all people as a memorial of Creation.", + verses: ["Genesis 2:1-3", "Exodus 20:8-11", "Mark 2:27-28"]), + + Belief(id: 21, title: "Stewardship", + summary: "We are God's stewards, entrusted by Him with time and opportunities, abilities and possessions, and the blessings of the earth and its resources.", + verses: ["Genesis 1:26-28", "1 Chronicles 29:14", "Malachi 3:8-12"]), + + Belief(id: 22, title: "Christian Behavior", + summary: "We are called to be a godly people who think, feel, and act in harmony with biblical principles in all aspects of personal and social life.", + verses: ["Romans 12:1-2", "1 Corinthians 10:31", "Philippians 4:8"]), + + Belief(id: 23, title: "Marriage and the Family", + summary: "Marriage was divinely established in Eden and affirmed by Jesus to be a lifelong union between a man and a woman in loving companionship.", + verses: ["Genesis 2:18-25", "Matthew 19:3-9", "Ephesians 5:21-33"]), + + Belief(id: 24, title: "Christ's Ministry in the Heavenly Sanctuary", + summary: "There is a sanctuary in heaven, the true tabernacle that the Lord set up and not humans. In it Christ ministers on our behalf, making available to believers the benefits of His atoning sacrifice.", + verses: ["Hebrews 8:1-5", "Hebrews 4:14-16", "Daniel 8:14"]), + + Belief(id: 25, title: "The Second Coming of Christ", + summary: "The second coming of Christ is the blessed hope of the church, the grand climax of the gospel. The Savior's coming will be literal, personal, visible, and worldwide.", + verses: ["Titus 2:13", "John 14:1-3", "Acts 1:9-11"]), + + Belief(id: 26, title: "Death and Resurrection", + summary: "The wages of sin is death. But God, who alone is immortal, will grant eternal life to His redeemed. Until that day death is an unconscious state for all people.", + verses: ["Romans 6:23", "1 Timothy 6:15-16", "Ecclesiastes 9:5-6"]), + + Belief(id: 27, title: "The Millennium and the End of Sin", + summary: "The millennium is the thousand-year reign of Christ with His saints in heaven between the first and second resurrections. During this time the wicked dead will be judged.", + verses: ["Revelation 20:1-6", "Jeremiah 4:23-26", "Revelation 21:1-5"]), + + Belief(id: 28, title: "The New Earth", + summary: "On the new earth, in which righteousness dwells, God will provide an eternal home for the redeemed and a perfect environment for everlasting life, love, joy, and learning in His presence.", + verses: ["2 Peter 3:13", "Isaiah 35", "Revelation 21:1-7"]) + + // End of beliefs + ] + + @State private var selectedBelief: Belief? + + private func formatVerseForURL(_ verse: String) -> String { + // Convert "Romans 4:11" to "rom.4.11" + let bookMap = [ + "Genesis": "gen", "Exodus": "exo", "Leviticus": "lev", "Numbers": "num", + "Deuteronomy": "deu", "Joshua": "jos", "Judges": "jdg", "Ruth": "rut", + "1 Samuel": "1sa", "2 Samuel": "2sa", "1 Kings": "1ki", "2 Kings": "2ki", + "1 Chronicles": "1ch", "2 Chronicles": "2ch", "Ezra": "ezr", "Nehemiah": "neh", + "Esther": "est", "Job": "job", "Psalm": "psa", "Psalms": "psa", "Proverbs": "pro", + "Ecclesiastes": "ecc", "Song of Solomon": "sng", "Isaiah": "isa", "Jeremiah": "jer", + "Lamentations": "lam", "Ezekiel": "ezk", "Daniel": "dan", "Hosea": "hos", + "Joel": "jol", "Amos": "amo", "Obadiah": "oba", "Jonah": "jon", + "Micah": "mic", "Nahum": "nam", "Habakkuk": "hab", "Zephaniah": "zep", + "Haggai": "hag", "Zechariah": "zec", "Malachi": "mal", "Matthew": "mat", + "Mark": "mrk", "Luke": "luk", "John": "jhn", "Acts": "act", + "Romans": "rom", "1 Corinthians": "1co", "2 Corinthians": "2co", "Galatians": "gal", + "Ephesians": "eph", "Philippians": "php", "Colossians": "col", "1 Thessalonians": "1th", + "2 Thessalonians": "2th", "1 Timothy": "1ti", "2 Timothy": "2ti", "Titus": "tit", + "Philemon": "phm", "Hebrews": "heb", "James": "jas", "1 Peter": "1pe", + "2 Peter": "2pe", "1 John": "1jn", "2 John": "2jn", "3 John": "3jn", + "Jude": "jud", "Revelation": "rev" + ] + + let components = verse.components(separatedBy: " ") + guard components.count >= 2 else { return verse.lowercased() } + + // Handle book name (including numbered books like "1 Corinthians") + var bookName = "" + var remainingComponents: [String] = components + + if let firstComponent = components.first, let _ = Int(firstComponent) { + if components.count >= 2 { + bookName = components[0] + " " + components[1] + remainingComponents = Array(components.dropFirst(2)) + } + } else { + bookName = components[0] + remainingComponents = Array(components.dropFirst()) + } + + guard let bookCode = bookMap[bookName] else { return verse.lowercased() } + + // Format chapter and verse + let reference = remainingComponents.joined(separator: "") + .replacingOccurrences(of: ":", with: ".") + .replacingOccurrences(of: "-", with: "-") + + return "\(bookCode).\(reference)" + } + + var body: some View { + List { + Text("The Seventh-day Adventist Church's 28 Fundamental Beliefs are a concise expression of our core beliefs. These beliefs reveal God's character and His plan for our lives.") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.vertical, 8) + .listRowSeparator(.hidden) + + ForEach(beliefs) { belief in + Button(action: { + selectedBelief = belief + }) { + HStack { + Text("\(belief.id).") + .font(.headline) + .foregroundColor(.secondary) + .frame(width: 30, alignment: .leading) + + Text(belief.title) + .font(.headline) + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + .navigationTitle("Our Beliefs") + .sheet(item: $selectedBelief) { belief in + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(belief.summary) + .font(.body) + .padding(.horizontal) + + if !belief.verses.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Key Verses") + .font(.headline) + .padding(.horizontal) + + ForEach(belief.verses, id: \.self) { verse in + Button(action: { + let formattedVerse = formatVerseForURL(verse) + if let url = URL(string: "https://www.bible.com/bible/1/\(formattedVerse)") { + UIApplication.shared.open(url) + } + }) { + Text(verse) + .foregroundColor(.primary) + Spacer() + Image(systemName: "book.fill") + .foregroundColor(.secondary) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(.systemBackground)) + } + } + .padding(.vertical) + .background(Color(.secondarySystemBackground)) + } + } + .padding(.vertical) + } + .navigationTitle(belief.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + selectedBelief = nil + } + } + } + } + } + } +} diff --git a/Views/BulletinView.swift b/Views/BulletinView.swift new file mode 100644 index 0000000..3aeb4c8 --- /dev/null +++ b/Views/BulletinView.swift @@ -0,0 +1,192 @@ +@preconcurrency import SwiftUI +@preconcurrency import WebKit + +struct BulletinView: View { + @State private var isLoading = true + @Environment(\.dismiss) private var dismiss + + var body: some View { + WebViewWithRefresh(url: URL(string: "https://rtsda.updates.church")!, isLoading: $isLoading) + .navigationTitle("Bulletin") + .navigationBarTitleDisplayMode(.inline) + .background(Color(uiColor: .systemBackground)) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if isLoading { + ProgressView() + .progressViewStyle(.circular) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + dismiss() + }) { + Image(systemName: "xmark") + .foregroundColor(.primary) + } + } + } + } +} + +struct WebViewWithRefresh: UIViewRepresentable { + let url: URL + @Binding var isLoading: Bool + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + + // Add swipe gesture recognizer + let swipeGesture = UISwipeGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleSwipe(_:))) + swipeGesture.direction = .right + webView.addGestureRecognizer(swipeGesture) + + // Configure refresh control with custom appearance + let refreshControl = UIRefreshControl() + refreshControl.addTarget(context.coordinator, + action: #selector(Coordinator.handleRefresh), + for: .valueChanged) + // Set the refresh control's background color + refreshControl.backgroundColor = .clear + webView.scrollView.refreshControl = refreshControl + + // Set background colors + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + + let request = URLRequest(url: url) + webView.load(request) + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + // No updates needed + } + + class Coordinator: NSObject, WKNavigationDelegate { + var parent: WebViewWithRefresh + + init(_ parent: WebViewWithRefresh) { + self.parent = parent + } + + @objc func handleSwipe(_ gesture: UISwipeGestureRecognizer) { + print("🔄 Swipe detected") + if gesture.state == .ended { + if let webView = gesture.view as? WKWebView { + print("📱 Attempting to trigger back action") + + // JavaScript to click the Chakra UI back button + let script = """ + function findBackButton() { + // Common back button selectors + var selectors = [ + 'button[aria-label="Go back"]', + 'button.chakra-button[aria-label*="back"]', + 'button.chakra-button svg[aria-label*="back"]', + 'button.chakra-button span svg[aria-hidden="true"]', + 'button svg[data-icon="arrow-left"]', + 'button.chakra-button svg', + 'button.chakra-button' + ]; + + for (var i = 0; i < selectors.length; i++) { + var buttons = document.querySelectorAll(selectors[i]); + for (var j = 0; j < buttons.length; j++) { + var button = buttons[j]; + // Check if it looks like a back button + if (button.textContent.toLowerCase().includes('back') || + button.getAttribute('aria-label')?.toLowerCase().includes('back') || + button.innerHTML.toLowerCase().includes('back')) { + console.log('Found back button:', button.outerHTML); + return button; + } + } + } + console.log('No back button found'); + return null; + } + + var backButton = findBackButton(); + if (backButton) { + backButton.click(); + true; + } else { + false; + } + """ + + webView.evaluateJavaScript(script) { result, error in + if let error = error { + print("❌ JavaScript error: \(error.localizedDescription)") + } else if let success = result as? Bool { + print(success ? "✅ Back button clicked" : "❌ No back button found") + } + } + } + } + } + + @objc func handleRefresh(sender: UIRefreshControl) { + parent.isLoading = true + if let webView = sender.superview?.superview as? WKWebView { + // Clear all website data + WKWebsiteDataStore.default().removeData( + ofTypes: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache], + modifiedSince: Date(timeIntervalSince1970: 0) + ) { + DispatchQueue.main.async { + // Create a fresh request + if let url = webView.url { + let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData) + webView.load(request) + } + } + } + } + } + + // Navigation delegate methods + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + parent.isLoading = true + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + parent.isLoading = false + webView.scrollView.refreshControl?.endRefreshing() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + parent.isLoading = false + webView.scrollView.refreshControl?.endRefreshing() + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + parent.isLoading = false + webView.scrollView.refreshControl?.endRefreshing() + } + + // Handle back navigation + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if navigationAction.navigationType == .backForward { + decisionHandler(.cancel) + parent.isLoading = false + return + } + decisionHandler(.allow) + } + } +} + +#Preview { + NavigationStack { + BulletinView() + } +} diff --git a/Views/CachedAsyncImage.swift b/Views/CachedAsyncImage.swift new file mode 100644 index 0000000..abf0581 --- /dev/null +++ b/Views/CachedAsyncImage.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct CachedAsyncImage: View { + private let url: URL? + private let scale: CGFloat + private let content: (Image) -> Content + private let placeholder: () -> Placeholder + + init( + url: URL?, + scale: CGFloat = 1.0, + @ViewBuilder content: @escaping (Image) -> Content, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.url = url + self.scale = scale + self.content = content + self.placeholder = placeholder + } + + var body: some View { + Group { + if let url = url { + AsyncImage( + url: url, + scale: scale, + transaction: Transaction(animation: .easeInOut) + ) { phase in + switch phase { + case .empty: + placeholder() + case .success(let image): + content(image) + .task { + await storeImageInCache(image: image, url: url) + } + case .failure(_): + placeholder() + @unknown default: + placeholder() + } + } + .task { + await loadImageFromCache(url: url) + } + } else { + placeholder() + } + } + } + + private func loadImageFromCache(url: URL) async { + guard let cachedImage = await ImageCache.shared.image(for: url) else { return } + _ = content(Image(uiImage: cachedImage)) + } + + private func storeImageInCache(image: Image, url: URL) async { + // Convert SwiftUI Image to UIImage and cache it + let renderer = ImageRenderer(content: content(image)) + if let uiImage = renderer.uiImage { + await ImageCache.shared.setImage(uiImage, for: url) + } + } +} \ No newline at end of file diff --git a/Views/ContactFormView.swift b/Views/ContactFormView.swift new file mode 100644 index 0000000..f8750f8 --- /dev/null +++ b/Views/ContactFormView.swift @@ -0,0 +1,210 @@ +import SwiftUI + +struct ContactFormView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = ContactFormViewModel() + @FocusState private var focusedField: Field? + var isModal: Bool = false + + enum Field { + case firstName, lastName, email, phone, message + } + + var body: some View { + NavigationStack { + Form { + Section { + Text("Use this form to get in touch with us for any reason - whether you have questions, need prayer, want to request Bible studies, learn more about our church, or would like to connect with our pastoral team.") + .foregroundColor(.secondary) + } + + Section { + TextField("First Name (Required)", text: $viewModel.firstName) + .focused($focusedField, equals: .firstName) + .textContentType(.givenName) + + TextField("Last Name (Required)", text: $viewModel.lastName) + .focused($focusedField, equals: .lastName) + .textContentType(.familyName) + + TextField("Email (Required)", text: $viewModel.email) + .focused($focusedField, equals: .email) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .autocapitalization(.none) + if !viewModel.email.isEmpty && !viewModel.isValidEmail(viewModel.email) { + Text("Please enter a valid email address") + .foregroundColor(.red) + } + + TextField("Phone", text: $viewModel.phone) + .focused($focusedField, equals: .phone) + .keyboardType(.phonePad) + .textContentType(.telephoneNumber) + .onChange(of: viewModel.phone) { oldValue, newValue in + viewModel.phone = viewModel.formatPhoneNumber(newValue) + } + if !viewModel.phone.isEmpty && !viewModel.isValidPhone(viewModel.phone) { + Text("Please enter a valid phone number") + .foregroundColor(.red) + } + } + + Section(header: Text("Message (Required)")) { + TextEditor(text: $viewModel.message) + .focused($focusedField, equals: .message) + .frame(minHeight: 100) + } + + Section { + Button(action: { + Task { + focusedField = nil // Dismiss keyboard + await viewModel.submit() + } + }) { + HStack { + Spacer() + Text("Submit") + Spacer() + } + } + .disabled(!viewModel.isValid || viewModel.isSubmitting) + } + } + .navigationTitle("Contact Us") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if isModal { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + focusedField = nil + dismiss() + } + } + } + } + .alert("Error", isPresented: .constant(viewModel.error != nil)) { + Button("OK") { + viewModel.error = nil + } + } message: { + Text(viewModel.error ?? "") + } + .alert("Success", isPresented: $viewModel.isSubmitted) { + Button("OK") { + viewModel.reset() + } + } message: { + Text("Thank you for your message! We'll get back to you soon.") + } + } + } +} + +class ContactFormViewModel: ObservableObject { + @Published var firstName = "" + @Published var lastName = "" + @Published var email = "" + @Published var phone = "" + @Published var message = "" + @Published var error: String? + @Published var isSubmitting = false + @Published var isSubmitted = false + + var isValid: Bool { + !firstName.isEmpty && + !lastName.isEmpty && + !email.isEmpty && + isValidEmail(email) && + !message.isEmpty && + (phone.isEmpty || isValidPhone(phone)) + } + + func reset() { + // Reset all fields + firstName = "" + lastName = "" + email = "" + phone = "" + message = "" + + // Reset state + error = nil + isSubmitting = false + isSubmitted = false + } + + func formatPhoneNumber(_ value: String) -> String { + // Remove all non-digits + let digits = value.replacingOccurrences(of: "[^0-9]", with: "", options: .regularExpression) + + // Format the number + if digits.isEmpty { + return "" + } else if digits.count <= 3 { + return "(\(digits))" + } else if digits.count <= 6 { + return "(\(digits.prefix(3))) \(digits.dropFirst(3))" + } else { + let areaCode = digits.prefix(3) + let middle = digits.dropFirst(3).prefix(3) + let last = digits.dropFirst(6).prefix(4) + return "(\(areaCode)) \(middle)-\(last)" + } + } + + func isValidEmail(_ email: String) -> Bool { + let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailRegex) + return emailPredicate.evaluate(with: email) + } + + func isValidPhone(_ phone: String) -> Bool { + let phoneRegex = "^\\([0-9]{3}\\) [0-9]{3}-[0-9]{4}$" + let phonePredicate = NSPredicate(format:"SELF MATCHES %@", phoneRegex) + return phonePredicate.evaluate(with: phone) + } + + @MainActor + func submit() async { + guard isValid else { return } + + isSubmitting = true + error = nil + + do { + let url = URL(string: "https://contact.rockvilletollandsda.church/api/contact")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let payload = [ + "first_name": firstName, + "last_name": lastName, + "email": email, + "phone": phone, + "message": message + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + + isSubmitted = true + reset() + + } catch { + self.error = "There was an error submitting your message. Please try again." + print("Error submitting form:", error) + reset() + } + + isSubmitting = false + } +} \ No newline at end of file diff --git a/Views/ContentView.swift b/Views/ContentView.swift new file mode 100644 index 0000000..10ecdc2 --- /dev/null +++ b/Views/ContentView.swift @@ -0,0 +1,551 @@ +import SwiftUI +import SafariServices +import AVKit + +struct ContentView: View { + @State private var selectedTab = 0 + + var body: some View { + TabView(selection: $selectedTab) { + HomeView() + .tabItem { + Label("Home", systemImage: "house.fill") + } + .tag(0) + + BulletinView() + .tabItem { + Label("Bulletin", systemImage: "newspaper.fill") + } + .tag(1) + + NavigationStack { + EventsView() + } + .tabItem { + Label("Events", systemImage: "calendar") + } + .tag(2) + + NavigationStack { + MessagesView() + } + .tabItem { + Label("Messages", systemImage: "video.fill") + } + .tag(3) + + MoreView() + .tabItem { + Label("More", systemImage: "ellipsis") + } + .tag(4) + } + .navigationBarHidden(true) + } +} + +// MARK: - Constants +enum ChurchContact { + static let email = "info@rockvilletollandsda.org" + static var emailUrl: String { + "mailto:\(email)" + } + static let phone = "860-875-0450" + static var phoneUrl: String { + "tel://\(phone.replacingOccurrences(of: "-", with: ""))" + } + static let facebook = "https://www.facebook.com/rockvilletollandsdachurch/" +} + +struct HomeView: View { + @State private var scrollTarget: ScrollTarget? + @State private var showingSafariView = false + @State private var safariURL: URL? + @State private var showSheet = false + @State private var sheetContent: AnyView? + @State private var showSuccessAlert = false + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + enum ScrollTarget { + case serviceTimes + } + + var body: some View { + GeometryReader { geometry in + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + // Hero Image Section + VStack(spacing: 0) { + Image("church_hero") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geometry.size.width) + .frame(height: horizontalSizeClass == .compact ? 350 : geometry.size.height * 0.35) + .offset(y: 30) + .clipped() + .overlay( + LinearGradient( + gradient: Gradient(colors: [.clear, .black.opacity(0.5)]), + startPoint: .top, + endPoint: .bottom + ) + ) + } + .edgesIgnoringSafeArea(.top) + + // Content Section + if horizontalSizeClass == .compact { + VStack(spacing: 16) { + quickLinksSection + aboutUsSection + } + .padding() + } else { + HStack(alignment: .top, spacing: 16) { + quickLinksSection + .frame(maxWidth: geometry.size.width * 0.4) + aboutUsSection + } + .padding() + } + } + .frame(minHeight: geometry.size.height) + } + .onChange(of: scrollTarget) { _, target in + if let target { + withAnimation { + proxy.scrollTo(target, anchor: .top) + } + scrollTarget = nil + } + } + } + } + .navigationTitle("") + .toolbar { + ToolbarItem(placement: .principal) { + Image("church_logo") + .resizable() + .scaledToFit() + .frame(height: 40) + } + } + .sheet(isPresented: $showingSafariView) { + if let url = safariURL { + SafariView(url: url) + .ignoresSafeArea() + } + } + .sheet(isPresented: $showSheet) { + if let content = sheetContent { + content + } + } + .alert("Success", isPresented: $showSuccessAlert) { + Button("OK", role: .cancel) { } + } message: { + Text("Thank you for your message! We'll get back to you soon.") + } + } + + private var quickLinksSection: some View { + VStack(alignment: .leading, spacing: horizontalSizeClass == .compact ? 16 : 8) { + Text("Quick Links") + .font(.custom("Montserrat-Bold", size: horizontalSizeClass == .compact ? 24 : 20)) + + quickLinksGrid + } + } + + private var quickLinksGrid: some View { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: horizontalSizeClass == .compact ? 16 : 8), + GridItem(.flexible(), spacing: horizontalSizeClass == .compact ? 16 : 8) + ], + spacing: horizontalSizeClass == .compact ? 16 : 8 + ) { + QuickLinkButton(title: "Contact Us", icon: "envelope.fill") { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootViewController = window.rootViewController { + let contactFormView = ContactFormView(isModal: true) + let hostingController = UIHostingController(rootView: NavigationStack { contactFormView }) + rootViewController.present(hostingController, animated: true) + } + } + + QuickLinkButton(title: "Directions", icon: "location.fill") { + if let url = URL(string: "https://maps.apple.com/?address=9+Hartford+Turnpike,+Tolland,+CT+06084") { + UIApplication.shared.open(url) + } + } + + QuickLinkButton(title: "Call Us", icon: "phone.fill") { + if let url = URL(string: ChurchContact.phoneUrl) { + UIApplication.shared.open(url) + } + } + + QuickLinkButton(title: "Give Online", icon: "heart.fill") { + if let url = URL(string: "https://adventistgiving.org/donate/AN4MJG") { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + } + } + + private var aboutUsSection: some View { + VStack(alignment: .leading, spacing: horizontalSizeClass == .compact ? 16 : 8) { + Text("About Us") + .font(.custom("Montserrat-Bold", size: horizontalSizeClass == .compact ? 24 : 20)) + + aboutUsContent + } + } + + private var aboutUsContent: some View { + VStack(alignment: .leading, spacing: horizontalSizeClass == .compact ? 16 : 8) { + Text("We are a vibrant, welcoming Seventh-day Adventist church community located in Tolland, Connecticut. Our mission is to share God's love through worship, fellowship, and service.") + .font(.body) + .foregroundColor(.secondary) + + Divider() + .padding(.vertical, 8) + + VStack(alignment: .leading, spacing: 16) { + Text("Service Times") + .font(.custom("Montserrat-Bold", size: 20)) + .id(ScrollTarget.serviceTimes) + + VStack(spacing: 12) { + ServiceTimeRow(day: "Saturday", time: "9:15 AM", name: "Sabbath School") + ServiceTimeRow(day: "Saturday", time: "11:00 AM", name: "Worship Service") + ServiceTimeRow(day: "Wednesday", time: "6:30 PM", name: "Prayer Meeting") + } + } + } + } +} + +struct QuickLinkButton: View { + let title: String + let icon: String + var color: Color = Color(hex: "fb8b23") + var action: () -> Void + + var body: some View { + VStack { + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundColor(color) + Text(title) + .font(.custom("Montserrat-Medium", size: 14)) + .foregroundColor(.primary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color(UIColor.secondarySystemBackground)) + .cornerRadius(12) + .onTapGesture(perform: action) + } +} + +struct ServiceTimeRow: View { + let day: String + let time: String + let name: String + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(day) + .font(.custom("Montserrat-Regular", size: 14)) + .foregroundColor(.secondary) + Text(time) + .font(.custom("Montserrat-SemiBold", size: 16)) + } + + Spacer() + + Text(name) + .font(.custom("Montserrat-Regular", size: 16)) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } +} + +struct FilterChip: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.custom("Montserrat-Medium", size: 14)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.accentColor : Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.5), lineWidth: 1) + ) + ) + .foregroundColor(isSelected ? .white : .primary) + } + .buttonStyle(.plain) + } +} + +struct FilterSection: View { + let title: String + let items: [String] + let selectedItem: String? + let onSelect: (String?) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(title) + .font(.custom("Montserrat-SemiBold", size: 14)) + .foregroundColor(.secondary) + + Spacer() + + if selectedItem != nil { + Button("Clear") { + onSelect(nil) + } + .font(.custom("Montserrat-Regular", size: 12)) + .foregroundColor(.accentColor) + } + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + FilterChip( + title: "All", + isSelected: selectedItem == nil, + action: { onSelect(nil) } + ) + + ForEach(items, id: \.self) { item in + FilterChip( + title: item, + isSelected: selectedItem == item, + action: { onSelect(item) } + ) + } + } + .padding(.bottom, 4) // Extra padding for shadow + } + } + } +} + +struct FilterPicker: View { + @Binding var selectedYear: String? + @Binding var selectedMonth: String? + @Binding var selectedMediaType: JellyfinService.MediaType + let availableYears: [String] + let availableMonths: [String] + + var body: some View { + VStack(spacing: 16) { + // Media Type Toggle + Picker("Media Type", selection: $selectedMediaType) { + Text("Sermons").tag(JellyfinService.MediaType.sermons) + Text("Live Archives").tag(JellyfinService.MediaType.livestreams) + } + .pickerStyle(.segmented) + + // Filters + VStack(spacing: 16) { + FilterSection( + title: "YEAR", + items: availableYears, + selectedItem: selectedYear, + onSelect: { year in + selectedYear = year + selectedMonth = nil + } + ) + + if selectedYear != nil { + FilterSection( + title: "MONTH", + items: availableMonths, + selectedItem: selectedMonth, + onSelect: { month in + selectedMonth = month + } + ) + } + } + + // Active Filters Summary + if selectedYear != nil || selectedMonth != nil { + HStack { + Text("Showing:") + .font(.custom("Montserrat-Regular", size: 12)) + .foregroundColor(.secondary) + + if let month = selectedMonth { + Text(month) + .font(.custom("Montserrat-Medium", size: 12)) + } + + if let year = selectedYear { + Text(year) + .font(.custom("Montserrat-Medium", size: 12)) + } + + Spacer() + + Button("Clear All") { + selectedYear = nil + selectedMonth = nil + } + .font(.custom("Montserrat-Medium", size: 12)) + .foregroundColor(.accentColor) + } + .padding(.top, -8) + } + } + } +} + +struct SafariView: UIViewControllerRepresentable { + let url: URL + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> SFSafariViewController { + let config = SFSafariViewController.Configuration() + config.entersReaderIfAvailable = false + let controller = SFSafariViewController(url: url, configuration: config) + controller.preferredControlTintColor = UIColor(named: "AccentColor") + controller.dismissButtonStyle = .done + return controller + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { + } +} + +struct MoreView: View { + @State private var showingSafariView = false + @State private var safariURL: URL? + @State private var showSheet = false + @State private var sheetContent: AnyView? + @State private var showSuccessAlert = false + + var body: some View { + NavigationStack { + List { + Section("Resources") { + Link(destination: URL(string: AppAvailabilityService.Schemes.bible)!) { + Label("Bible", systemImage: "book.fill") + } + + Link(destination: URL(string: AppAvailabilityService.Schemes.sabbathSchool)!) { + Label("Sabbath School", systemImage: "book.fill") + } + + Link(destination: URL(string: AppAvailabilityService.Schemes.egw)!) { + Label("EGW Writings", systemImage: "book.closed.fill") + } + + Link(destination: URL(string: AppAvailabilityService.Schemes.hymnal)!) { + Label("SDA Hymnal", systemImage: "music.note") + } + } + + Section("Connect") { + NavigationLink { + ContactFormView() + } label: { + Label("Contact Us", systemImage: "envelope.fill") + } + + Link(destination: URL(string: ChurchContact.phoneUrl)!) { + Label("Call Us", systemImage: "phone.fill") + } + + Link(destination: URL(string: ChurchContact.facebook)!) { + Label("Facebook", systemImage: "link") + } + + Link(destination: URL(string: "https://maps.apple.com/?address=9+Hartford+Turnpike,+Tolland,+CT+06084")!) { + Label("Directions", systemImage: "map.fill") + } + } + + Section("About") { + NavigationLink { + BeliefsView() + } label: { + Label("Our Beliefs", systemImage: "heart.text.square.fill") + } + } + + Section("App Info") { + HStack { + Label("Version", systemImage: "info.circle.fill") + Spacer() + Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0") + .foregroundColor(.secondary) + } + } + } + .navigationTitle("More") + .sheet(isPresented: $showingSafariView) { + if let url = safariURL { + SafariView(url: url) + .ignoresSafeArea() + } + } + .sheet(isPresented: $showSheet) { + if let content = sheetContent { + content + } + } + .alert("Success", isPresented: $showSuccessAlert) { + Button("OK", role: .cancel) { } + } message: { + Text("Thank you for your message! We'll get back to you soon.") + } + } + } +} + +extension UIApplication { + var scrollView: UIScrollView? { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + return window.rootViewController?.view.subviews.first { $0 is UIScrollView } as? UIScrollView + } + return nil + } +} + +enum NavigationDestination { + case prayerRequest +} + +extension UIScrollView { + func scrollToBottom() { + let bottomPoint = CGPoint(x: 0, y: contentSize.height - bounds.size.height) + setContentOffset(bottomPoint, animated: true) + } +} + +#Preview { + ContentView() +} diff --git a/Views/EventCard.swift b/Views/EventCard.swift new file mode 100644 index 0000000..07cc91a --- /dev/null +++ b/Views/EventCard.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct EventCard: View { + let event: Event + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .center, spacing: 12) { + if let imageURL = event.imageURL { + CachedAsyncImage(url: imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Color.gray.opacity(0.2) + } + .frame(height: 160) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + VStack(alignment: .center, spacing: 8) { + Text(event.title) + .font(.headline) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 8) { + Text(event.category.rawValue) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.1)) + .clipShape(Capsule()) + + if event.reoccuring != .none { + Text(event.reoccuring.rawValue.replacingOccurrences(of: "_", with: " ").capitalized) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.purple.opacity(0.1)) + .clipShape(Capsule()) + } + } + .fixedSize(horizontal: true, vertical: false) + + HStack { + Image(systemName: "calendar") + Text(event.formattedDateTime) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + .padding() + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .contentShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 2) + } + .buttonStyle(PlainButtonStyle()) + } +} + +struct EventCardButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .animation(.easeInOut(duration: 0.2), value: configuration.isPressed) + } +} \ No newline at end of file diff --git a/Views/EventDetailView.swift b/Views/EventDetailView.swift new file mode 100644 index 0000000..4d146ea --- /dev/null +++ b/Views/EventDetailView.swift @@ -0,0 +1,149 @@ +import SwiftUI + +struct EventDetailView: View { + let event: Event + @Environment(\.dismiss) var dismiss + @State private var showingAlert = false + @State private var alertTitle = "" + @State private var alertMessage = "" + + var body: some View { + ScrollView { + VStack(alignment: .center, spacing: 20) { + // Header with dismiss button + HStack { + Button("Done") { + dismiss() + } + .padding() + + Spacer() + } + + // Image if available + if let imageURL = event.imageURL { + AsyncImage(url: imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 200) + } placeholder: { + Color.gray.opacity(0.2) + .frame(height: 200) + } + } + + VStack(alignment: .center, spacing: 16) { + // Title and tags + Text(event.title) + .font(.title2.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 8) { + Text(event.category.rawValue) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.1)) + .clipShape(Capsule()) + + if event.reoccuring != .none { + Text(event.reoccuring.rawValue.replacingOccurrences(of: "_", with: " ").capitalized) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.purple.opacity(0.1)) + .clipShape(Capsule()) + } + } + + // Date and location + VStack(spacing: 12) { + HStack { + Image(systemName: "calendar") + Text(event.formattedDateTime) + } + .font(.subheadline) + .foregroundStyle(.secondary) + + if event.hasLocation { + Button { + Task { + await event.openInMaps() + } + } label: { + HStack { + Image(systemName: "mappin.and.ellipse") + Text(event.displayLocation) + .multilineTextAlignment(.center) + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + // Description + if !event.plainDescription.isEmpty { + let lines = event.plainDescription.components(separatedBy: .newlines) + VStack(alignment: .center, spacing: 4) { + ForEach(lines, id: \.self) { line in + if line.starts(with: "📞") { + Button { + Task { + event.callPhone() + } + } label: { + Text(line) + .font(.body) + .multilineTextAlignment(.center) + .foregroundStyle(.blue) + } + } else { + Text(line) + .font(.body) + .multilineTextAlignment(.center) + } + } + } + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 8) + } + + // Add to Calendar button + Button { + Task { + await event.addToCalendar { success, error in + if success { + alertTitle = "Success" + alertMessage = "Event has been added to your calendar" + } else { + alertTitle = "Error" + alertMessage = error?.localizedDescription ?? "Failed to add event to calendar" + } + showingAlert = true + } + } + } label: { + Label("Add to Calendar", systemImage: "calendar.badge.plus") + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(.top, 16) + } + .padding(.horizontal) + } + } + .background(Color(.systemBackground)) + .alert(alertTitle, isPresented: $showingAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(alertMessage) + } + } +} diff --git a/Views/EventsView.swift b/Views/EventsView.swift new file mode 100644 index 0000000..b95bbbc --- /dev/null +++ b/Views/EventsView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct EventsView: View { + @StateObject private var viewModel = EventsViewModel() + @State private var selectedEvent: Event? + + var body: some View { + NavigationStack { + Group { + if viewModel.isLoading { + ProgressView() + } else if let error = viewModel.error { + VStack(spacing: 16) { + Text("Unable to load events") + .font(.headline) + Text(error.localizedDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + Button("Try Again") { + Task { + await viewModel.loadEvents() + } + } + .buttonStyle(.bordered) + } + } else if viewModel.events.isEmpty { + Text("No upcoming events") + .font(.headline) + .foregroundStyle(.secondary) + } else { + ScrollView { + LazyVStack(spacing: 24) { + ForEach(viewModel.events) { event in + EventCard(event: event) { + selectedEvent = event + } + .padding(.horizontal) + } + } + .padding(.vertical) + } + .refreshable { + await viewModel.loadEvents() + } + } + } + .navigationTitle("Events") + .sheet(item: $selectedEvent) { event in + EventDetailView(event: event) + } + .task { + await viewModel.loadEvents() + } + } + } +} + +#Preview { + EventsView() +} diff --git a/Views/JellyfinPlayerView.swift b/Views/JellyfinPlayerView.swift new file mode 100644 index 0000000..6bd56d8 --- /dev/null +++ b/Views/JellyfinPlayerView.swift @@ -0,0 +1,39 @@ +import SwiftUI +import AVKit + +class PlayerViewController: AVPlayerViewController { + override func viewDidLoad() { + super.viewDidLoad() + allowsPictureInPicturePlayback = true + } +} + +struct JellyfinPlayerView: View { + let videoUrl: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + if let url = URL(string: videoUrl) { + VideoViewControllerRepresentable(url: url, dismiss: dismiss) + .ignoresSafeArea() + .onAppear { + try? AVAudioSession.sharedInstance().setCategory(.playback) + try? AVAudioSession.sharedInstance().setActive(true) + } + } + } +} + +struct VideoViewControllerRepresentable: UIViewControllerRepresentable { + let url: URL + let dismiss: DismissAction + + func makeUIViewController(context: Context) -> PlayerViewController { + let controller = PlayerViewController() + controller.player = AVPlayer(url: url) + controller.player?.play() + return controller + } + + func updateUIViewController(_ uiViewController: PlayerViewController, context: Context) {} +} \ No newline at end of file diff --git a/Views/LivestreamCard.swift b/Views/LivestreamCard.swift new file mode 100644 index 0000000..78c4bf2 --- /dev/null +++ b/Views/LivestreamCard.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct LivestreamCard: View { + let livestream: Message + + var body: some View { + NavigationLink { + if let url = URL(string: livestream.videoUrl) { + VideoPlayerView(url: url) + } + } label: { + VStack(alignment: .leading, spacing: 8) { + // Thumbnail + AsyncImage(url: URL(string: livestream.thumbnailUrl ?? "")) { image in + image.resizable() + } placeholder: { + Color.gray.opacity(0.3) + } + .aspectRatio(16/9, contentMode: .fill) + .clipped() + + // Content + VStack(alignment: .leading, spacing: 6) { + Text(livestream.title) + .font(.custom("Montserrat-SemiBold", size: 18)) + .lineLimit(2) + + HStack { + Text(livestream.speaker) + .font(.custom("Montserrat-Regular", size: 14)) + .foregroundColor(.secondary) + + Spacer() + + Text(livestream.formattedDate) + .font(.custom("Montserrat-Regular", size: 14)) + .foregroundColor(.secondary) + } + + if livestream.isLiveStream { + Text("LIVE") + .font(.custom("Montserrat-Bold", size: 12)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(4) + } + } + .padding(.horizontal) + .padding(.bottom) + } + .background(Color(.systemBackground)) + .cornerRadius(8) + } + } +} \ No newline at end of file diff --git a/Views/MessageCard.swift b/Views/MessageCard.swift new file mode 100644 index 0000000..22e1592 --- /dev/null +++ b/Views/MessageCard.swift @@ -0,0 +1,60 @@ +import SwiftUI +import AVKit + +struct MessageCard: View { + let message: Message + + var body: some View { + NavigationLink { + if let url = URL(string: message.videoUrl) { + VideoPlayerView(url: url) + } + } label: { + VStack(alignment: .leading, spacing: 8) { + // Thumbnail + AsyncImage(url: URL(string: message.thumbnailUrl ?? "")) { image in + image + .resizable() + .aspectRatio(16/9, contentMode: .fill) + .clipped() + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.2)) + .aspectRatio(16/9, contentMode: .fill) + } + + VStack(alignment: .leading, spacing: 6) { + Text(message.title) + .font(.custom("Montserrat-SemiBold", size: 18)) + .lineLimit(2) + + HStack { + Text(message.speaker) + .font(.custom("Montserrat-Regular", size: 14)) + .foregroundColor(.secondary) + + Spacer() + + Text(message.formattedDate) + .font(.custom("Montserrat-Regular", size: 14)) + .foregroundColor(.secondary) + } + + if message.isLiveStream { + Text("LIVE") + .font(.custom("Montserrat-Bold", size: 12)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(4) + } + } + .padding() + } + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(radius: 2) + } + } +} \ No newline at end of file diff --git a/Views/MessagesView.swift b/Views/MessagesView.swift new file mode 100644 index 0000000..6ad8986 --- /dev/null +++ b/Views/MessagesView.swift @@ -0,0 +1,304 @@ +import SwiftUI +import AVKit + +struct MessagesView: View { + @StateObject private var viewModel = MessagesViewModel() + @State private var selectedYear: String? + @State private var selectedMonth: String? + @State private var showingFilters = false + + var body: some View { + ScrollView { + VStack(spacing: 16) { + // Header with Filter Button and Active Filters + VStack(spacing: 8) { + HStack { + Button { + showingFilters = true + } label: { + HStack { + Image(systemName: "line.3.horizontal.decrease.circle.fill") + Text("Filter") + } + .font(.headline) + .foregroundStyle(.blue) + } + + Spacer() + + if selectedYear != nil || selectedMonth != nil { + Button(action: { + selectedYear = nil + selectedMonth = nil + viewModel.filterContent(year: nil, month: nil) + }) { + Text("Clear Filters") + .foregroundStyle(.red) + .font(.subheadline) + } + } + } + + // Active Filters Display + if selectedYear != nil || selectedMonth != nil || viewModel.currentMediaType == .livestreams { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + // Media Type Pill + Text(viewModel.currentMediaType == .sermons ? "Sermons" : "Live Archives") + .font(.footnote.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue.opacity(0.1)) + .foregroundStyle(.blue) + .clipShape(Capsule()) + + // Year Pill (if selected) + if let year = selectedYear { + Text(year) + .font(.footnote.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue.opacity(0.1)) + .foregroundStyle(.blue) + .clipShape(Capsule()) + } + + // Month Pill (if selected) + if let month = selectedMonth { + Text(formatMonth(month)) + .font(.footnote.weight(.medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue.opacity(0.1)) + .foregroundStyle(.blue) + .clipShape(Capsule()) + } + } + .padding(.horizontal) + } + } + } + .padding(.horizontal) + + // Content Section + if viewModel.isLoading { + ProgressView() + .padding() + } else if viewModel.error != nil { + VStack { + Text("Error loading content") + .foregroundColor(.red) + Button("Try Again") { + Task { + await viewModel.refreshContent() + } + } + } + .padding() + } else if viewModel.filteredMessages.isEmpty { + Text("No messages available") + .padding() + } else { + LazyVStack(spacing: 16) { + if let livestream = viewModel.livestream { + MessageCard(message: livestream) + .padding(.horizontal) + } + + ForEach(viewModel.filteredMessages) { message in + MessageCard(message: message) + .padding(.horizontal) + } + } + } + } + } + .navigationTitle(viewModel.currentMediaType == .sermons ? "Sermons" : "Live Archives") + .refreshable { + await viewModel.refreshContent() + } + .sheet(isPresented: $showingFilters) { + NavigationStack { + FilterView( + currentMediaType: $viewModel.currentMediaType, + selectedYear: $selectedYear, + selectedMonth: $selectedMonth, + availableYears: viewModel.availableYears, + availableMonths: viewModel.availableMonths, + onMediaTypeChange: { newType in + Task { + await viewModel.loadContent(mediaType: newType) + } + }, + onYearChange: { year in + if let year = year { + viewModel.updateMonthsForYear(year) + } + viewModel.filterContent(year: year, month: selectedMonth) + }, + onMonthChange: { month in + viewModel.filterContent(year: selectedYear, month: month) + } + ) + .navigationTitle("Filter Content") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Done") { + showingFilters = false + } + } + + ToolbarItem(placement: .topBarTrailing) { + if selectedYear != nil || selectedMonth != nil { + Button("Reset") { + selectedYear = nil + selectedMonth = nil + viewModel.filterContent(year: nil, month: nil) + showingFilters = false + } + .foregroundStyle(.red) + } + } + } + } + .presentationDetents([.medium]) + } + } + + private func formatMonth(_ month: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MM" + + if let date = formatter.date(from: month) { + formatter.dateFormat = "MMM" + return formatter.string(from: date) + } + return month + } +} + +struct FilterView: View { + @Binding var currentMediaType: JellyfinService.MediaType + @Binding var selectedYear: String? + @Binding var selectedMonth: String? + let availableYears: [String] + let availableMonths: [String] + let onMediaTypeChange: (JellyfinService.MediaType) -> Void + let onYearChange: (String?) -> Void + let onMonthChange: (String?) -> Void + + var body: some View { + Form { + Section("Content Type") { + Picker("Type", selection: $currentMediaType) { + Text("Sermons").tag(JellyfinService.MediaType.sermons) + Text("Live Archives").tag(JellyfinService.MediaType.livestreams) + } + .pickerStyle(.segmented) + .onChange(of: currentMediaType) { oldValue, newValue in + selectedYear = nil + selectedMonth = nil + onMediaTypeChange(newValue) + } + } + + Section("Year") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + MessageFilterChip( + title: "All", + isSelected: selectedYear == nil, + action: { + selectedYear = nil + selectedMonth = nil + onYearChange(nil) + } + ) + + ForEach(availableYears, id: \.self) { year in + MessageFilterChip( + title: year, + isSelected: selectedYear == year, + action: { + selectedYear = year + selectedMonth = nil + onYearChange(year) + } + ) + } + } + .padding(.horizontal, 4) + } + } + + if selectedYear != nil && !availableMonths.isEmpty { + Section("Month") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + MessageFilterChip( + title: "All", + isSelected: selectedMonth == nil, + action: { + selectedMonth = nil + onMonthChange(nil) + } + ) + + ForEach(availableMonths, id: \.self) { month in + MessageFilterChip( + title: formatMonth(month), + isSelected: selectedMonth == month, + action: { + selectedMonth = month + onMonthChange(month) + } + ) + } + } + .padding(.horizontal, 4) + } + } + } + } + } + + private func formatMonth(_ month: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MM" + + if let date = formatter.date(from: month) { + formatter.dateFormat = "MMM" + return formatter.string(from: date) + } + return month + } +} + +struct MessageFilterChip: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(isSelected ? Color.blue : Color.gray.opacity(0.15)) + .foregroundStyle(isSelected ? .white : .primary) + .clipShape(Capsule()) + } + } +} + +// Helper extension for optional binding in Picker +extension Binding where Value == String? { + func toUnwrapped(defaultValue: String) -> Binding { + Binding( + get: { self.wrappedValue ?? defaultValue }, + set: { self.wrappedValue = $0 } + ) + } +} diff --git a/Views/OwncastView.swift b/Views/OwncastView.swift new file mode 100644 index 0000000..d377878 --- /dev/null +++ b/Views/OwncastView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct OwncastView: View { + @StateObject private var viewModel = OwncastViewModel() + + var body: some View { + NavigationStack { + Group { + if let streamUrl = viewModel.streamUrl { + VideoPlayerView(url: streamUrl) + } else { + ContentUnavailableView { + Label("Stream Offline", systemImage: "video.slash") + } description: { + Text("The live stream is currently offline") + } + } + } + .navigationTitle("Live Stream") + .navigationBarTitleDisplayMode(.inline) + } + .task { + await viewModel.checkStreamStatus() + } + } +} \ No newline at end of file diff --git a/Views/SplashScreenView.swift b/Views/SplashScreenView.swift new file mode 100644 index 0000000..02d41c6 --- /dev/null +++ b/Views/SplashScreenView.swift @@ -0,0 +1,143 @@ +import SwiftUI + +struct SplashScreenView: View { + @StateObject private var configService = ConfigService.shared + @State private var isActive = false + @State private var size = 0.8 + @State private var opacity = 0.5 + @State private var bibleVerse = "" + @State private var bibleReference = "" + + // Timing constants + private let fadeInDuration: Double = 0.5 + private let baseDisplayDuration: Double = 1.0 + private let timePerWord: Double = 0.15 + private let minDisplayDuration: Double = 1.5 + private let maxDisplayDuration: Double = 3.0 + + private func calculateDisplayDuration(for text: String) -> Double { + let words = text.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } + let calculatedDuration = baseDisplayDuration + (Double(words.count) * timePerWord) + return min(max(calculatedDuration, minDisplayDuration), maxDisplayDuration) + } + + var body: some View { + if isActive { + ContentView() + } else { + ZStack { + LinearGradient(gradient: Gradient(colors: [ + Color(hex: "3b0d11"), + Color(hex: "21070a") + ]), startPoint: .top, endPoint: .bottom) + .ignoresSafeArea() + + VStack(spacing: 20) { + Image("sdalogo") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + + Text("Rockville-Tolland SDA Church") + .font(.custom("Montserrat-SemiBold", size: 24)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + Rectangle() + .fill(Color(hex: "fb8b23")) + .frame(width: 60, height: 2) + + Text(bibleVerse) + .font(.custom("Lora-Italic", size: 18)) + .foregroundColor(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + .lineSpacing(4) + + Text(bibleReference) + .font(.custom("Montserrat-Regular", size: 14)) + .foregroundColor(Color(hex: "fb8b23")) + } + .padding() + .scaleEffect(size) + .opacity(opacity) + .task { + // First load config + await configService.loadConfig() + + do { + let verse = try await BibleService.shared.getRandomVerse() + bibleVerse = verse.verse + bibleReference = verse.reference + + // Calculate display duration based on verse length + let displayDuration = calculateDisplayDuration(for: verse.verse) + + // Start fade in animation after verse is loaded + withAnimation(.easeIn(duration: fadeInDuration)) { + self.size = 0.9 + self.opacity = 1.0 + } + + // Wait for fade in + calculated display duration before transitioning + DispatchQueue.main.asyncAfter(deadline: .now() + fadeInDuration + displayDuration) { + withAnimation { + self.isActive = true + } + } + } catch { + // Fallback to a default verse if API fails + bibleVerse = "For God so loved the world that he gave his one and only Son, that whoever believes in him shall not perish but have eternal life." + bibleReference = "John 3:16" + + // Calculate duration for fallback verse + let displayDuration = calculateDisplayDuration(for: bibleVerse) + + // Use same timing for fallback verse + withAnimation(.easeIn(duration: fadeInDuration)) { + self.size = 0.9 + self.opacity = 1.0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + fadeInDuration + displayDuration) { + withAnimation { + self.isActive = true + } + } + } + } + } + } + } +} + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +#Preview { + SplashScreenView() +} diff --git a/Views/VideoPlayerView.swift b/Views/VideoPlayerView.swift new file mode 100644 index 0000000..46ace44 --- /dev/null +++ b/Views/VideoPlayerView.swift @@ -0,0 +1,113 @@ +import SwiftUI +import AVKit + +struct VideoPlayerView: View { + let url: URL + @Environment(\.dismiss) private var dismiss + @State private var isInPiPMode = false + @State private var isLoading = true + + var body: some View { + ZStack { + VideoPlayerViewController(url: url, isInPiPMode: $isInPiPMode, isLoading: $isLoading) + .ignoresSafeArea() + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(isInPiPMode) + + if isLoading { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(1.5) + .tint(.white) + } + } + .onAppear { + setupAudio() + } + } + + private func setupAudio() { + try? AVAudioSession.sharedInstance().setCategory(.playback) + try? AVAudioSession.sharedInstance().setActive(true) + } +} + +struct VideoPlayerViewController: UIViewControllerRepresentable { + let url: URL + @Binding var isInPiPMode: Bool + @Binding var isLoading: Bool + + func makeUIViewController(context: Context) -> AVPlayerViewController { + let player = AVPlayer(url: url) + let controller = AVPlayerViewController() + controller.player = player + controller.allowsPictureInPicturePlayback = true + controller.delegate = context.coordinator + + // Add observer for buffering state + player.addObserver(context.coordinator, forKeyPath: "timeControlStatus", options: [.old, .new], context: nil) + context.coordinator.setPlayerController(controller) + + player.play() + return controller + } + + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(isInPiPMode: $isInPiPMode, isLoading: $isLoading) + } + + class Coordinator: NSObject, AVPlayerViewControllerDelegate { + @Binding var isInPiPMode: Bool + @Binding var isLoading: Bool + internal var playerController: AVPlayerViewController? + private var wasPlayingBeforeDismiss = false + + init(isInPiPMode: Binding, isLoading: Binding) { + _isInPiPMode = isInPiPMode + _isLoading = isLoading + super.init() + } + + func setPlayerController(_ controller: AVPlayerViewController) { + playerController = controller + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { + if keyPath == "timeControlStatus", + let player = object as? AVPlayer { + DispatchQueue.main.async { + self.isLoading = player.timeControlStatus == .waitingToPlayAtSpecifiedRate + } + } + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + if let player = playerController?.player { + wasPlayingBeforeDismiss = (player.rate > 0) + } + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + if wasPlayingBeforeDismiss, let player = playerController?.player { + // Prevent the player from pausing during transition + player.rate = 1.0 + } + } + + func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { + isInPiPMode = true + } + + func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { + isInPiPMode = false + } + + deinit { + if let player = playerController?.player { + player.removeObserver(self, forKeyPath: "timeControlStatus") + } + } + } +} \ No newline at end of file