Initial commit: RTSDA iOS app

This commit is contained in:
RTSDA 2025-02-03 16:15:57 -05:00
commit 1f5f6f4883
58 changed files with 5886 additions and 0 deletions

99
.gitignore vendored Normal file
View file

@ -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

View file

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View file

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View file

@ -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")
}

76
Info.plist Normal file
View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>remote-notification</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>org.rockvilletollandsda.rtsda.refresh</string>
<string>org.rockvilletollandsda.rtsda.processing</string>
</array>
<key>UIAppFonts</key>
<array>
<string>Montserrat-Regular.ttf</string>
<string>Montserrat-SemiBold.ttf</string>
<string>Lora-Italic.ttf</string>
</array>
<key>NSCalendarsUsageDescription</key>
<string>We need access to your calendar to add church events that you're interested in attending.</string>
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
<string>We can add church events to your calendar without viewing your existing events.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to provide directions to church events and activities.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Your location is used to provide directions to church events and activities.</string>
<key>NSCameraUsageDescription</key>
<string>The camera can be used to scan QR codes for event registration or take photos during church events.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Access to your photo library allows you to share photos from church events and save event QR codes.</string>
<key>NSMicrophoneUsageDescription</key>
<string>The microphone is used for live stream audio and voice interactions during online events.</string>
<key>NSContactsUsageDescription</key>
<string>Access to contacts allows you to easily share church events with friends and family.</string>
<key>NSRemindersUsageDescription</key>
<string>Reminders can be set for upcoming church events and activities you're interested in.</string>
<key>NSAppleMusicUsageDescription</key>
<string>Access to media library is used for hymns and worship music during services.</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>sschool</string>
<string>com.googleusercontent.apps.443920152945-d0kf5h2dubt0jbcntq8l0qeg6lbpgn60</string>
<string>egw-ios</string>
<string>com.whiteestate.egwwritings.syncviewer.new</string>
<string>adventisthymnarium</string>
<string>youversion</string>
<string>fb</string>
<string>tiktok</string>
<string>spotify</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict/>
</dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>AVInitialRouteSharingPolicy</key>
<string>LongFormVideo</string>
</dict>
</plist>

46
LICENSE Normal file
View file

@ -0,0 +1,46 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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

View file

@ -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<Bool, Error>) 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<Bool, Error>) 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"
}
}
}

49
Models/Config.swift Normal file
View file

@ -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
}
}

400
Models/Event.swift Normal file
View file

@ -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: "<table[^>]*>.*?</table>", with: "", options: .regularExpression)
cleanedText = cleanedText.replacingOccurrences(of: "<div[^>]*>", with: "", options: .regularExpression)
cleanedText = cleanedText.replacingOccurrences(of: "</div>", with: "\n", options: .regularExpression)
// Replace other HTML tags
cleanedText = cleanedText.replacingOccurrences(of: "<br\\s*/?>", with: "\n", options: .regularExpression)
cleanedText = cleanedText.replacingOccurrences(of: "<p>", with: "", options: .regularExpression)
cleanedText = cleanedText.replacingOccurrences(of: "</p>", with: "\n", options: .regularExpression)
cleanedText = cleanedText.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
// Decode common HTML entities
let htmlEntities = [
"&nbsp;": " ",
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&apos;": "'",
"&#x27;": "'",
"&#x2F;": "/",
"&#39;": "'",
"&#47;": "/",
"&rsquo;": "'",
"&mdash;": ""
]
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
}
}

57
Models/Message.swift Normal file
View file

@ -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
}
}

28
Models/Sermon.swift Normal file
View file

@ -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"
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

8
RTSDA.entitlements Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>

View file

@ -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 = "<group>"; };
EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
EA1C835E2D43EA4900D8B78F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
EA1C835F2D43EA4900D8B78F /* PermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsManager.swift; sourceTree = "<group>"; };
EA1C83612D43EA4900D8B78F /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
EA1C83622D43EA4900D8B78F /* Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = "<group>"; };
EA1C83632D43EA4900D8B78F /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = "<group>"; };
EA1C83642D43EA4900D8B78F /* Sermon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sermon.swift; sourceTree = "<group>"; };
EA1C83662D43EA4900D8B78F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
EA1C83682D43EA4900D8B78F /* Lora-Italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Lora-Italic.ttf"; sourceTree = "<group>"; };
EA1C83692D43EA4900D8B78F /* Lora-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Lora-Regular.ttf"; sourceTree = "<group>"; };
EA1C836A2D43EA4900D8B78F /* Montserrat-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Montserrat-Regular.ttf"; sourceTree = "<group>"; };
EA1C836B2D43EA4900D8B78F /* Montserrat-SemiBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Montserrat-SemiBold.ttf"; sourceTree = "<group>"; };
EA1C836E2D43EA4900D8B78F /* RTSDA.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RTSDA.entitlements; sourceTree = "<group>"; };
EA1C836F2D43EA4900D8B78F /* RTSDA.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = RTSDA.xcodeproj; sourceTree = "<group>"; };
EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTSDAApp.swift; sourceTree = "<group>"; };
EA1C83762D43EA4900D8B78F /* AppAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAvailabilityService.swift; sourceTree = "<group>"; };
EA1C83772D43EA4900D8B78F /* BibleService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BibleService.swift; sourceTree = "<group>"; };
EA1C83782D43EA4900D8B78F /* ConfigService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigService.swift; sourceTree = "<group>"; };
EA1C83792D43EA4900D8B78F /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
EA1C837A2D43EA4900D8B78F /* JellyfinService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinService.swift; sourceTree = "<group>"; };
EA1C837B2D43EA4900D8B78F /* OwnCastService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OwnCastService.swift; sourceTree = "<group>"; };
EA1C837C2D43EA4900D8B78F /* PocketBaseService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PocketBaseService.swift; sourceTree = "<group>"; };
EA1C838C2D43EA4900D8B78F /* EventsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsViewModel.swift; sourceTree = "<group>"; };
EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesViewModel.swift; sourceTree = "<group>"; };
EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OwncastViewModel.swift; sourceTree = "<group>"; };
EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SermonBrowserViewModel.swift; sourceTree = "<group>"; };
EA1C83912D43EA4900D8B78F /* BeliefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeliefsView.swift; sourceTree = "<group>"; };
EA1C83922D43EA4900D8B78F /* BulletinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulletinView.swift; sourceTree = "<group>"; };
EA1C83932D43EA4900D8B78F /* CachedAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedAsyncImage.swift; sourceTree = "<group>"; };
EA1C83942D43EA4900D8B78F /* ContactFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactFormView.swift; sourceTree = "<group>"; };
EA1C83952D43EA4900D8B78F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
EA1C83962D43EA4900D8B78F /* EventCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCard.swift; sourceTree = "<group>"; };
EA1C83972D43EA4900D8B78F /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = "<group>"; };
EA1C83982D43EA4900D8B78F /* EventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsView.swift; sourceTree = "<group>"; };
EA1C83992D43EA4900D8B78F /* JellyfinPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinPlayerView.swift; sourceTree = "<group>"; };
EA1C839A2D43EA4900D8B78F /* LivestreamCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamCard.swift; sourceTree = "<group>"; };
EA1C839B2D43EA4900D8B78F /* MessageCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCard.swift; sourceTree = "<group>"; };
EA1C839C2D43EA4900D8B78F /* MessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesView.swift; sourceTree = "<group>"; };
EA1C839D2D43EA4900D8B78F /* OwncastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OwncastView.swift; sourceTree = "<group>"; };
EA1C839E2D43EA4900D8B78F /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = "<group>"; };
EA1C839F2D43EA4900D8B78F /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
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 = "<group>";
};
EA1C835D2D43EA4900D8B78F /* Extensions */ = {
isa = PBXGroup;
children = (
EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
EA1C83602D43EA4900D8B78F /* Managers */ = {
isa = PBXGroup;
children = (
EA1C835F2D43EA4900D8B78F /* PermissionsManager.swift */,
);
path = Managers;
sourceTree = "<group>";
};
EA1C83652D43EA4900D8B78F /* Models */ = {
isa = PBXGroup;
children = (
EA1C83612D43EA4900D8B78F /* Config.swift */,
EA1C83622D43EA4900D8B78F /* Event.swift */,
EA1C83632D43EA4900D8B78F /* Message.swift */,
EA1C83642D43EA4900D8B78F /* Sermon.swift */,
);
path = Models;
sourceTree = "<group>";
};
EA1C83672D43EA4900D8B78F /* Preview Content */ = {
isa = PBXGroup;
children = (
EA1C83662D43EA4900D8B78F /* Preview Assets.xcassets */,
);
path = "Preview Content";
sourceTree = "<group>";
};
EA1C836C2D43EA4900D8B78F /* Fonts */ = {
isa = PBXGroup;
children = (
EA1C83682D43EA4900D8B78F /* Lora-Italic.ttf */,
EA1C83692D43EA4900D8B78F /* Lora-Regular.ttf */,
EA1C836A2D43EA4900D8B78F /* Montserrat-Regular.ttf */,
EA1C836B2D43EA4900D8B78F /* Montserrat-SemiBold.ttf */,
);
path = Fonts;
sourceTree = "<group>";
};
EA1C836D2D43EA4900D8B78F /* Resources */ = {
isa = PBXGroup;
children = (
EA1C836C2D43EA4900D8B78F /* Fonts */,
);
path = Resources;
sourceTree = "<group>";
};
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 = "<group>";
};
EA1C83902D43EA4900D8B78F /* ViewModels */ = {
isa = PBXGroup;
children = (
EA1C838C2D43EA4900D8B78F /* EventsViewModel.swift */,
EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */,
EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */,
EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
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 = "<group>";
};
EA1C83A12D43EA4900D8B78F /* Products */ = {
isa = PBXGroup;
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
EA2F9F7F2CF406E800B9F454 /* Products */ = {
isa = PBXGroup;
children = (
EA2F9F7E2CF406E800B9F454 /* RTSDA.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}

View file

@ -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 = "<group>";
};
EA2F9F9B2CF406E900B9F454 /* RTSDAUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = RTSDAUITests;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
EA2F9F7F2CF406E800B9F454 /* Products */ = {
isa = PBXGroup;
children = (
EA2F9F7E2CF406E800B9F454 /* RTSDA.app */,
EA2F9F8E2CF406E900B9F454 /* RTSDATests.xctest */,
EA2F9F982CF406E900B9F454 /* RTSDAUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

19
RTSDAApp.swift Normal file
View file

@ -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()
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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)")
}
}
}

View file

@ -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: "&quot;", with: "\"")
.replacingOccurrences(of: "&#39;", 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)
}
}

View file

@ -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()
}
}

23
Services/ImageCache.swift Normal file
View file

@ -0,0 +1,23 @@
import SwiftUI
actor ImageCache {
static let shared = ImageCache()
private let cache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
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)
}
}

View file

@ -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)"
}
}

View file

@ -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
)
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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<Void, Never>?
private var autoRefreshTask: Task<Void, Never>?
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<String>()
var monthsByYear: [String: Set<String>] = [:]
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<String>()
}
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<String>()
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 }
}
}

View file

@ -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
}
}
}

View file

@ -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()
}
}

259
Views/BeliefsView.swift Normal file
View file

@ -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
}
}
}
}
}
}
}

192
Views/BulletinView.swift Normal file
View file

@ -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()
}
}

View file

@ -0,0 +1,64 @@
import SwiftUI
struct CachedAsyncImage<Content: View, Placeholder: View>: 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)
}
}
}

210
Views/ContactFormView.swift Normal file
View file

@ -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
}
}

551
Views/ContentView.swift Normal file
View file

@ -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()
}

74
Views/EventCard.swift Normal file
View file

@ -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)
}
}

149
Views/EventDetailView.swift Normal file
View file

@ -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)
}
}
}

60
Views/EventsView.swift Normal file
View file

@ -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()
}

View file

@ -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) {}
}

View file

@ -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)
}
}
}

60
Views/MessageCard.swift Normal file
View file

@ -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)
}
}
}

304
Views/MessagesView.swift Normal file
View file

@ -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<String> {
Binding<String>(
get: { self.wrappedValue ?? defaultValue },
set: { self.wrappedValue = $0 }
)
}
}

26
Views/OwncastView.swift Normal file
View file

@ -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()
}
}
}

View file

@ -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()
}

113
Views/VideoPlayerView.swift Normal file
View file

@ -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<Bool>, isLoading: Binding<Bool>) {
_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")
}
}
}
}