RTSDA-iOS/Models/ContentModels.swift
RTSDA 00679f927c docs: Update README for v2.0 release and fix git remote URL
- Comprehensive README update documenting v2.0 architectural changes
- Updated git remote to ssh://rockvilleav@git.rockvilletollandsda.church:10443/RTSDA/RTSDA-iOS.git
- Documented unified ChurchService and 60% code reduction
- Added new features: Home Feed, responsive reading, enhanced UI
- Corrected license information (GPL v3 with church content copyright)
- Updated build instructions and technical stack details
2025-08-16 18:41:51 -04:00

419 lines
14 KiB
Swift

import Foundation
// MARK: - Content Models for RTSDA v2.0
struct ChurchEvent: Identifiable, Codable, Hashable {
let id: String
let title: String
let description: String
// Raw ISO timestamps for calendar/system APIs
let startTime: String
let endTime: String
// Formatted display strings from Rust (RTSDA Architecture Rules compliance)
let formattedTime: String // "6:00 PM - 8:00 PM"
let formattedDate: String // "Friday, August 15, 2025"
let formattedDateTime: String // "Friday, August 15, 2025 at 6:00 PM"
// Additional display fields from Rust (RTSDA Architecture Rules compliance)
let dayOfMonth: String // "15"
let monthAbbreviation: String // "AUG"
let timeString: String // "6:00 PM - 8:00 PM" (alias for formattedTime)
let isMultiDay: Bool // true if event spans multiple days
let detailedTimeDisplay: String // Full time range for detail views
let location: String
let locationUrl: String?
let image: String?
let thumbnail: String?
let category: String
let isFeatured: Bool
let recurringType: String?
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id, title, description, location, image, thumbnail, category
case startTime = "start_time"
case endTime = "end_time"
case formattedTime = "formatted_time"
case formattedDate = "formatted_date"
case formattedDateTime = "formatted_date_time"
case dayOfMonth = "day_of_month"
case monthAbbreviation = "month_abbreviation"
case timeString = "time_string"
case isMultiDay = "is_multi_day"
case detailedTimeDisplay = "detailed_time_display"
case locationUrl = "location_url"
case isFeatured = "is_featured"
case recurringType = "recurring_type"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
// All formatting now handled by Rust church-core crate (RTSDA Architecture Rules compliance)
/// Returns formatted date range for display - now using Rust-provided formattedDate
var formattedDateRange: String {
return formattedDate
}
// MARK: - Sample Data
static func sampleEvent() -> ChurchEvent {
return ChurchEvent(
id: "sample-event-1",
title: "Community Potluck",
description: "Join us for fellowship and food",
startTime: "2025-01-15T18:00:00-05:00",
endTime: "2025-01-15T20:00:00-05:00",
formattedTime: "6:00 PM - 8:00 PM",
formattedDate: "January 15, 2025",
formattedDateTime: "January 15, 2025 at 6:00 PM",
dayOfMonth: "15",
monthAbbreviation: "JAN",
timeString: "6:00 PM - 8:00 PM",
isMultiDay: false,
detailedTimeDisplay: "6:00 PM - 8:00 PM",
location: "Fellowship Hall",
locationUrl: nil,
image: nil,
thumbnail: nil,
category: "Social",
isFeatured: false,
recurringType: nil,
createdAt: "2025-01-10T09:00:00-05:00",
updatedAt: "2025-01-10T09:00:00-05:00"
)
}
}
struct Sermon: Identifiable, Codable {
let id: String
let title: String
let speaker: String
let description: String?
let date: String?
let audioUrl: String?
let videoUrl: String?
let duration: String?
let mediaType: String?
let thumbnail: String?
let image: String?
let scriptureReading: String?
// CodingKeys no longer needed - Rust now sends camelCase field names
var formattedDate: String {
return date ?? "Date unknown" // Already formatted by API
}
var durationFormatted: String? {
return duration // Already formatted by API
}
// MARK: - Sample Data
static func sampleSermon() -> Sermon {
return Sermon(
id: "sample-1",
title: "Walking in Faith During Difficult Times",
speaker: "Pastor John Smith",
description: "A message about trusting God during challenging times.",
date: "January 10th, 2025",
audioUrl: nil,
videoUrl: "https://example.com/video.mp4",
duration: "35:42",
mediaType: "Video",
thumbnail: nil,
image: nil,
scriptureReading: "Philippians 4:13"
)
}
}
struct ChurchBulletin: Identifiable, Codable {
let id: String
let title: String
let date: String
let sabbathSchool: String
let divineWorship: String
let scriptureReading: String
let sunset: String
let pdfPath: String?
let coverImage: String?
let isActive: Bool
enum CodingKeys: String, CodingKey {
case id, title, date, sunset
case sabbathSchool = "sabbath_school"
case divineWorship = "divine_worship"
case scriptureReading = "scripture_reading"
case pdfPath = "pdf_path"
case coverImage = "cover_image"
case isActive = "is_active"
}
var formattedDate: String {
// Parse ISO8601 or YYYY-MM-DD format and return US-friendly date
let formatter = ISO8601DateFormatter()
if let isoDate = formatter.date(from: date) {
let usFormatter = DateFormatter()
usFormatter.dateStyle = .long // "August 2, 2025"
return usFormatter.string(from: isoDate)
} else if date.contains("-") {
// Try parsing YYYY-MM-DD format
let components = date.split(separator: "-")
if components.count >= 3,
let year = Int(components[0]),
let month = Int(components[1]),
let day = Int(components[2]) {
let dateComponents = DateComponents(year: year, month: month, day: day)
if let parsedDate = Calendar.current.date(from: dateComponents) {
let usFormatter = DateFormatter()
usFormatter.dateStyle = .long // "August 2, 2025"
return usFormatter.string(from: parsedDate)
}
}
}
return date // Fallback to original if parsing fails
}
}
struct BibleVerse: Identifiable, Codable {
let text: String
let reference: String
let version: String?
let book: String?
let chapter: UInt32?
let verse: UInt32?
let category: String?
// Computed property for Identifiable
var id: String {
return reference + text.prefix(50) // Use reference + start of text as unique ID
}
}
// MARK: - API Response Models
struct EventsResponse: Codable {
let items: [ChurchEvent]
let total: Int
let page: Int
let perPage: Int
let hasMore: Bool
enum CodingKeys: String, CodingKey {
case items, total, page
case perPage = "per_page"
case hasMore = "has_more"
}
}
struct ContactSubmissionResult: Codable {
let success: Bool
let message: String?
}
// MARK: - Content Feed Item
enum FeedItemType {
case sermon(Sermon)
case event(ChurchEvent)
case bulletin(ChurchBulletin)
case verse(BibleVerse)
}
// MARK: - Rust Feed Item (from church-core)
struct RustFeedItem: Identifiable, Codable {
let id: String
let feedType: RustFeedItemType
enum CodingKeys: String, CodingKey {
case id
case feedType = "feed_type"
case timestamp
case priority
}
let timestamp: String // ISO8601 format
let priority: Int32
}
enum RustFeedItemType: Codable {
case event(ChurchEvent)
case sermon(Sermon)
case bulletin(ChurchBulletin)
case verse(BibleVerse)
enum CodingKeys: String, CodingKey {
case type
case event
case sermon
case bulletin
case verse
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "event":
let event = try container.decode(ChurchEvent.self, forKey: .event)
self = .event(event)
case "sermon":
let sermon = try container.decode(Sermon.self, forKey: .sermon)
self = .sermon(sermon)
case "bulletin":
let bulletin = try container.decode(ChurchBulletin.self, forKey: .bulletin)
self = .bulletin(bulletin)
case "verse":
let verse = try container.decode(BibleVerse.self, forKey: .verse)
self = .verse(verse)
default:
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown feed item type: \(type)"))
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .event(let event):
try container.encode("event", forKey: .type)
try container.encode(event, forKey: .event)
case .sermon(let sermon):
try container.encode("sermon", forKey: .type)
try container.encode(sermon, forKey: .sermon)
case .bulletin(let bulletin):
try container.encode("bulletin", forKey: .type)
try container.encode(bulletin, forKey: .bulletin)
case .verse(let verse):
try container.encode("verse", forKey: .type)
try container.encode(verse, forKey: .verse)
}
}
}
// MARK: - Swift Feed Item (legacy)
struct FeedItem: Identifiable {
let id = UUID()
let type: FeedItemType
let timestamp: Date
var title: String {
switch type {
case .sermon(let sermon):
return sermon.title
case .event(let event):
return event.title
case .bulletin(let bulletin):
return bulletin.title
case .verse(let verse):
return verse.reference
}
}
var subtitle: String? {
switch type {
case .sermon(let sermon):
return sermon.speaker
case .event(let event):
return event.formattedDate
case .bulletin(let bulletin):
return bulletin.formattedDate
case .verse(let verse):
return verse.text
}
}
}
// MARK: - Search Utilities
struct SearchUtils {
/// Searches events by title, description, location, and date
static func searchEvents(_ events: [ChurchEvent], searchText: String) -> [ChurchEvent] {
guard !searchText.isEmpty else { return events }
return events.filter { event in
let titleMatch = event.title.localizedCaseInsensitiveContains(searchText)
let descMatch = event.description.localizedCaseInsensitiveContains(searchText)
let locationMatch = event.location.localizedCaseInsensitiveContains(searchText)
let dateMatch = event.formattedDate.localizedCaseInsensitiveContains(searchText)
let smartDateMatch = checkSmartDateMatch(searchText: searchText, sermonDate: event.formattedDate)
return titleMatch || descMatch || locationMatch || dateMatch || smartDateMatch
}
}
/// Searches sermons by title, speaker, description, and date
static func searchSermons(_ sermons: [Sermon], searchText: String, contentType: String = "sermons") -> [Sermon] {
guard !searchText.isEmpty else { return sermons }
return sermons.filter { sermon in
let titleMatch = sermon.title.localizedCaseInsensitiveContains(searchText)
let speakerMatch = sermon.speaker.localizedCaseInsensitiveContains(searchText)
let descMatch = sermon.description?.localizedCaseInsensitiveContains(searchText) ?? false
let dateMatch = sermon.date?.localizedCaseInsensitiveContains(searchText) ?? false
let smartDateMatch = checkSmartDateMatch(searchText: searchText, sermonDate: sermon.date)
return titleMatch || speakerMatch || descMatch || dateMatch || smartDateMatch
}
}
/// Smart date matching for patterns like "January 2025", "Jan 2024", etc.
private static func checkSmartDateMatch(searchText: String, sermonDate: String?) -> Bool {
guard let sermonDate = sermonDate else { return false }
let searchWords = searchText.lowercased().components(separatedBy: .whitespaces).filter { !$0.isEmpty }
guard searchWords.count >= 2 else { return false }
// Check for month + year patterns like "January 2025" or "Jan 2024"
let monthNames = ["january", "february", "march", "april", "may", "june",
"july", "august", "september", "october", "november", "december"]
let monthAbbrevs = ["jan", "feb", "mar", "apr", "may", "jun",
"jul", "aug", "sep", "oct", "nov", "dec"]
for i in 0..<searchWords.count-1 {
let word1 = searchWords[i]
let word2 = searchWords[i+1]
// Check if first word is a month (full name or abbreviation)
if monthNames.contains(word1) || monthAbbrevs.contains(word1) {
// Convert month to full name for matching
let fullMonthName: String
if let monthIndex = monthAbbrevs.firstIndex(of: word1) {
fullMonthName = monthNames[monthIndex]
} else {
fullMonthName = word1
}
let sermonDateLower = sermonDate.lowercased()
// Check if second word is a year
if let year = Int(word2), year >= 2000 && year <= 2100 {
// Check if sermon date contains both the month and year
if sermonDateLower.contains(fullMonthName) && sermonDateLower.contains(String(year)) {
return true
}
}
// Check if second word could be a day OR part of a year
else if let number = Int(word2) {
// Check if sermon date contains both the month and this number anywhere
// This catches both day matches (e.g., "January 20th") and year substring matches (e.g., "January" + "20" in "2020")
if sermonDateLower.contains(fullMonthName) && sermonDateLower.contains(String(number)) {
return true
}
}
}
}
return false
}
}