RTSDA-iOS/Models/Event.swift
2025-02-03 16:15:57 -05:00

401 lines
15 KiB
Swift

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