
- 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
419 lines
14 KiB
Swift
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
|
|
}
|
|
} |