import Foundation import EventKit import UIKit struct Event: Identifiable, Codable { let id: String let title: String let description: String // Original HTML description let startDate: Date let endDate: Date let location: String? let locationURL: String? let image: String? let thumbnail: String? let category: EventCategory let isFeatured: Bool let reoccuring: ReoccurringType let isPublished: Bool let created: Date let updated: Date enum EventCategory: String, Codable { case service = "Service" case social = "Social" case ministry = "Ministry" case other = "Other" } enum ReoccurringType: String, Codable { case none = "" // For non-recurring events case daily = "DAILY" case weekly = "WEEKLY" case biweekly = "BIWEEKLY" case firstTuesday = "FIRST_TUESDAY" var calendarRecurrenceRule: EKRecurrenceRule? { switch self { case .none: return nil // No recurrence for one-time events case .daily: return EKRecurrenceRule( recurrenceWith: .daily, interval: 1, end: nil ) case .weekly: return EKRecurrenceRule( recurrenceWith: .weekly, interval: 1, end: nil ) case .biweekly: return EKRecurrenceRule( recurrenceWith: .weekly, interval: 2, end: nil ) case .firstTuesday: let tuesday = EKWeekday.tuesday return EKRecurrenceRule( recurrenceWith: .monthly, interval: 1, daysOfTheWeek: [EKRecurrenceDayOfWeek(tuesday)], daysOfTheMonth: nil, monthsOfTheYear: nil, weeksOfTheYear: nil, daysOfTheYear: nil, setPositions: [1], end: nil ) } } } var formattedDateTime: String { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .medium dateFormatter.timeStyle = .short dateFormatter.timeZone = .gmt // Use GMT to match database times exactly let startDateString = dateFormatter.string(from: startDate) let endTimeFormatter = DateFormatter() endTimeFormatter.timeStyle = .short endTimeFormatter.timeZone = .gmt // Use GMT to match database times exactly let endTimeString = endTimeFormatter.string(from: endDate) return "\(startDateString) • \(endTimeString)" } var hasLocation: Bool { return (location != nil && !location!.isEmpty) } var hasLocationUrl: Bool { return (locationURL != nil && !locationURL!.isEmpty) } var canOpenInMaps: Bool { return hasLocation } var displayLocation: String { if let location = location { return location } if let locationURL = locationURL { // Try to extract a readable location from the URL if let url = URL(string: locationURL) { let components = url.pathComponents if components.count > 1 { return components.last?.replacingOccurrences(of: "+", with: " ") ?? locationURL } } return locationURL } return "No location specified" } var imageURL: URL? { guard let image = image else { return nil } return URL(string: "https://pocketbase.rockvilletollandsda.church/api/files/events/\(id)/\(image)") } var thumbnailURL: URL? { guard let thumbnail = thumbnail else { return nil } return URL(string: "https://pocketbase.rockvilletollandsda.church/api/files/events/\(id)/\(thumbnail)") } func callPhone() { if let phoneNumber = extractPhoneNumber() { let cleanNumber = phoneNumber.replacingOccurrences(of: "[^0-9+]", with: "", options: .regularExpression) if let url = URL(string: "tel://\(cleanNumber)") { UIApplication.shared.open(url) } } } func extractPhoneNumber() -> String? { let phonePattern = #"Phone:.*?(\+\d{1})?[\s-]?\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})"# if let match = description.range(of: phonePattern, options: .regularExpression) { let phoneText = String(description[match]) let numberPattern = #"(\+\d{1})?[\s-]?\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})"# if let numberMatch = phoneText.range(of: numberPattern, options: .regularExpression) { return String(phoneText[numberMatch]) } } return nil } var plainDescription: String { // First remove all table structures and divs var cleanedText = description.replacingOccurrences(of: "]*>.*?", with: "", options: .regularExpression) cleanedText = cleanedText.replacingOccurrences(of: "]*>", with: "", options: .regularExpression) cleanedText = cleanedText.replacingOccurrences(of: "", with: "\n", options: .regularExpression) // Replace other HTML tags cleanedText = cleanedText.replacingOccurrences(of: "", with: "\n", options: .regularExpression) cleanedText = cleanedText.replacingOccurrences(of: "

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

", with: "\n", options: .regularExpression) cleanedText = cleanedText.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) // Decode common HTML entities let htmlEntities = [ " ": " ", "&": "&", "<": "<", ">": ">", """: "\"", "'": "'", "'": "'", "/": "/", "'": "'", "/": "/", "’": "'", "—": "—" ] for (entity, replacement) in htmlEntities { cleanedText = cleanedText.replacingOccurrences(of: entity, with: replacement) } // Format phone numbers with better pattern matching let phonePattern = #"(?m)^Phone:.*?(\+\d{1})?[\s-]?\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})"# cleanedText = cleanedText.replacingOccurrences( of: phonePattern, with: "📞 Phone: ($2) $3-$4", options: .regularExpression ) // Clean up whitespace while preserving intentional line breaks let lines = cleanedText.components(separatedBy: .newlines) let nonEmptyLines = lines.map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } return nonEmptyLines.joined(separator: "\n") } func openInMaps() async { let permissionsManager = await PermissionsManager.shared // We don't strictly need location permission to open maps, // but we'll request it for better functionality await permissionsManager.requestLocationAccess() if let locationURL = locationURL, let url = URL(string: locationURL) { await UIApplication.shared.open(url, options: [:]) } else if let location = location, !location.isEmpty { let searchQuery = location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? location if let mapsUrl = URL(string: "http://maps.apple.com/?q=\(searchQuery)") { await UIApplication.shared.open(mapsUrl, options: [:]) } } } func addToCalendar(completion: @escaping (Bool, Error?) -> Void) async { let permissionsManager = await PermissionsManager.shared let eventStore = EKEventStore() do { let accessGranted = await permissionsManager.requestCalendarAccess() if !accessGranted { await MainActor.run { completion(false, NSError(domain: "com.rtsda.calendar", code: 1, userInfo: [NSLocalizedDescriptionKey: "Calendar access is not available. You can enable it in Settings."])) } return } let event = EKEvent(eventStore: eventStore) // Set basic event details event.title = self.title event.notes = self.plainDescription event.startDate = self.startDate event.endDate = self.endDate event.location = self.location ?? self.locationURL // Set recurrence rule if applicable if let rule = self.reoccuring.calendarRecurrenceRule { event.recurrenceRules = [rule] } // Get the default calendar guard let calendar = eventStore.defaultCalendarForNewEvents else { await MainActor.run { completion(false, NSError(domain: "com.rtsda.calendar", code: 2, userInfo: [NSLocalizedDescriptionKey: "Could not access default calendar."])) } return } event.calendar = calendar try eventStore.save(event, span: .thisEvent) await MainActor.run { completion(true, nil) } } catch { await MainActor.run { completion(false, error) } } } enum CodingKeys: String, CodingKey { case id case title case description case startDate = "start_time" case endDate = "end_time" case location case locationURL = "location_url" case image case thumbnail case category case isFeatured = "is_featured" case reoccuring case isPublished = "is_published" case created case updated } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) title = try container.decode(String.self, forKey: .title) description = try container.decode(String.self, forKey: .description) // Try multiple date formats let startDateString = try container.decode(String.self, forKey: .startDate) let endDateString = try container.decode(String.self, forKey: .endDate) let createdString = try container.decode(String.self, forKey: .created) let updatedString = try container.decode(String.self, forKey: .updated) // Create formatters for different possible formats let formatters = [ { () -> DateFormatter in let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" formatter.timeZone = TimeZone(identifier: "America/New_York")! // Eastern Time return formatter }(), { () -> ISO8601DateFormatter in let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.timeZone = TimeZone(identifier: "America/New_York")! // Eastern Time return formatter }(), { () -> ISO8601DateFormatter in let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] formatter.timeZone = TimeZone(identifier: "America/New_York")! // Eastern Time 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 } }