From 7e6e0178ebc1676cafcb7e516b7404abb7654c8e Mon Sep 17 00:00:00 2001 From: RTSDA Date: Fri, 28 Mar 2025 23:00:00 -0400 Subject: [PATCH] Version 1.2: Integrate new digital bulletin system with improved UI and Bible verse detection --- Models/Bulletin.swift | 66 +++ RTSDA.xcodeproj/project.pbxproj | 8 +- Services/BulletinService.swift | 68 +++ Services/PocketBaseService.swift | 189 ++++++- ViewModels/BulletinViewModel.swift | 87 +++ Views/BulletinView.swift | 849 ++++++++++++++++++----------- Views/BulletinViews.swift | 192 +++++++ 7 files changed, 1148 insertions(+), 311 deletions(-) create mode 100644 Models/Bulletin.swift create mode 100644 Services/BulletinService.swift create mode 100644 ViewModels/BulletinViewModel.swift create mode 100644 Views/BulletinViews.swift diff --git a/Models/Bulletin.swift b/Models/Bulletin.swift new file mode 100644 index 0000000..cc3a243 --- /dev/null +++ b/Models/Bulletin.swift @@ -0,0 +1,66 @@ +import Foundation + +struct BulletinSection: Identifiable { + let id = UUID() + let title: String + let content: String +} + +struct Bulletin: Identifiable, Codable { + let id: String + let title: String + let date: Date + let sections: [BulletinSection] + let pdfUrl: String? + let isActive: Bool + let created: Date + let updated: Date + + enum CodingKeys: String, CodingKey { + case id + case title + case date + case sections + case pdfUrl = "pdf_url" + case isActive = "is_active" + 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) + date = try container.decode(Date.self, forKey: .date) + pdfUrl = try container.decodeIfPresent(String.self, forKey: .pdfUrl) + isActive = try container.decode(Bool.self, forKey: .isActive) + created = try container.decode(Date.self, forKey: .created) + updated = try container.decode(Date.self, forKey: .updated) + + // Decode sections + let sectionsData = try container.decode([[String: String]].self, forKey: .sections) + sections = sectionsData.map { section in + BulletinSection( + title: section["title"] ?? "", + content: section["content"] ?? "" + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(title, forKey: .title) + try container.encode(date, forKey: .date) + try container.encodeIfPresent(pdfUrl, forKey: .pdfUrl) + try container.encode(isActive, forKey: .isActive) + try container.encode(created, forKey: .created) + try container.encode(updated, forKey: .updated) + + // Encode sections + let sectionsData = sections.map { section in + ["title": section.title, "content": section.content] + } + try container.encode(sectionsData, forKey: .sections) + } +} \ No newline at end of file diff --git a/RTSDA.xcodeproj/project.pbxproj b/RTSDA.xcodeproj/project.pbxproj index 7a4cb40..5f31d32 100644 --- a/RTSDA.xcodeproj/project.pbxproj +++ b/RTSDA.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + EA019ECF2D978167002FC58F /* BulletinViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA019ECE2D978167002FC58F /* BulletinViewModel.swift */; }; 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 */; }; @@ -49,6 +50,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + EA019ECE2D978167002FC58F /* BulletinViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulletinViewModel.swift; sourceTree = ""; }; 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 = ""; }; EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; @@ -189,6 +191,7 @@ EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */, EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */, EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */, + EA019ECE2D978167002FC58F /* BulletinViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -365,6 +368,7 @@ EA1C83C22D43EA4900D8B78F /* MessagesViewModel.swift in Sources */, EA1C83C32D43EA4900D8B78F /* OwnCastService.swift in Sources */, EA1C83C42D43EA4900D8B78F /* SplashScreenView.swift in Sources */, + EA019ECF2D978167002FC58F /* BulletinViewModel.swift in Sources */, EA1C83C52D43EA4900D8B78F /* JellyfinPlayerView.swift in Sources */, EA1C83C62D43EA4900D8B78F /* AppAvailabilityService.swift in Sources */, ); @@ -527,7 +531,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -570,7 +574,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Services/BulletinService.swift b/Services/BulletinService.swift new file mode 100644 index 0000000..c1f0ac6 --- /dev/null +++ b/Services/BulletinService.swift @@ -0,0 +1,68 @@ +import Foundation + +class BulletinService { + static let shared = BulletinService() + private let pocketBaseService = PocketBaseService.shared + + private init() {} + + func getBulletins(activeOnly: Bool = true) async throws -> [Bulletin] { + var urlString = "\(pocketBaseService.baseURL)/api/collections/bulletins/records?sort=-date" + + if activeOnly { + urlString += "&filter=(is_active=true)" + } + + 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, + (200...299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + struct BulletinResponse: Codable { + let items: [Bulletin] + } + + let bulletinResponse = try decoder.decode(BulletinResponse.self, from: data) + return bulletinResponse.items + } + + func getBulletin(id: String) async throws -> Bulletin { + let urlString = "\(pocketBaseService.baseURL)/api/collections/bulletins/records/\(id)" + + 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, + (200...299).contains(httpResponse.statusCode) else { + throw URLError(.badServerResponse) + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + return try decoder.decode(Bulletin.self, from: data) + } + + func getLatestBulletin() async throws -> Bulletin? { + let bulletins = try await getBulletins(activeOnly: true) + return bulletins.first + } +} \ No newline at end of file diff --git a/Services/PocketBaseService.swift b/Services/PocketBaseService.swift index fd3a902..9e086cb 100644 --- a/Services/PocketBaseService.swift +++ b/Services/PocketBaseService.swift @@ -1,11 +1,151 @@ import Foundation +struct BulletinSection: Identifiable { + let id = UUID() + let title: String + let content: String +} + +struct Bulletin: Identifiable, Codable { + let id: String + let collectionId: String + let title: String + let date: Date + let divineWorship: String + let sabbathSchool: String + let scriptureReading: String + let sunset: String + let pdf: String? + let isActive: Bool + let created: Date + let updated: Date + + enum CodingKeys: String, CodingKey { + case id + case collectionId = "collectionId" + case title + case date + case divineWorship = "divine_worship" + case sabbathSchool = "sabbath_school" + case scriptureReading = "scripture_reading" + case sunset + case pdf + case isActive = "is_active" + 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) + collectionId = try container.decode(String.self, forKey: .collectionId) + title = try container.decode(String.self, forKey: .title) + date = try container.decode(Date.self, forKey: .date) + divineWorship = try container.decode(String.self, forKey: .divineWorship) + sabbathSchool = try container.decode(String.self, forKey: .sabbathSchool) + scriptureReading = try container.decode(String.self, forKey: .scriptureReading) + sunset = try container.decode(String.self, forKey: .sunset) + pdf = try container.decodeIfPresent(String.self, forKey: .pdf) + isActive = try container.decode(Bool.self, forKey: .isActive) + created = try container.decode(Date.self, forKey: .created) + updated = try container.decode(Date.self, forKey: .updated) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(collectionId, forKey: .collectionId) + try container.encode(title, forKey: .title) + try container.encode(date, forKey: .date) + try container.encode(divineWorship, forKey: .divineWorship) + try container.encode(sabbathSchool, forKey: .sabbathSchool) + try container.encode(scriptureReading, forKey: .scriptureReading) + try container.encode(sunset, forKey: .sunset) + try container.encodeIfPresent(pdf, forKey: .pdf) + try container.encode(isActive, forKey: .isActive) + try container.encode(created, forKey: .created) + try container.encode(updated, forKey: .updated) + } + + // Computed property to get the PDF URL + var pdfUrl: String { + if let pdf = pdf { + return "https://pocketbase.rockvilletollandsda.church/api/files/\(collectionId)/\(id)/\(pdf)" + } + return "" + } + + // Computed property to get formatted content + var content: String { + """ + Divine Worship + \(divineWorship) + + Sabbath School + \(sabbathSchool) + + Scripture Reading + \(scriptureReading) + + Sunset Information + \(sunset) + """ + } +} + class PocketBaseService { static let shared = PocketBaseService() - private let baseURL = "https://pocketbase.rockvilletollandsda.church" + private let baseURL = "https://pocketbase.rockvilletollandsda.church/api/collections" private init() {} + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" + return formatter + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + print("Attempting to decode date string: \(dateString)") + + // Try ISO8601 first + if let date = ISO8601DateFormatter().date(from: dateString) { + print("Successfully decoded ISO8601 date") + return date + } + + // Try various date formats + let formatters = [ + "yyyy-MM-dd'T'HH:mm:ss.SSSZ", + "yyyy-MM-dd'T'HH:mm:ssZ", + "yyyy-MM-dd HH:mm:ss.SSSZ", + "yyyy-MM-dd HH:mm:ssZ", + "yyyy-MM-dd" + ] + + for format in formatters { + let formatter = DateFormatter() + formatter.dateFormat = format + if let date = formatter.date(from: dateString) { + print("Successfully decoded date with format: \(format)") + return date + } + } + + print("Failed to decode date string with any format") + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Date string '\(dateString)' does not match any expected format" + ) + } + return decoder + }() + struct EventResponse: Codable { let page: Int let perPage: Int @@ -22,10 +162,18 @@ class PocketBaseService { } } + struct BulletinResponse: Codable { + let page: Int + let perPage: Int + let totalItems: Int + let totalPages: Int + let items: [Bulletin] + } + @MainActor func fetchConfig() async throws -> Config { let recordId = "nn753t8o2t1iupd" - let urlString = "\(baseURL)/api/collections/config/records/\(recordId)" + let urlString = "\(baseURL)/config/records/\(recordId)" guard let url = URL(string: urlString) else { throw URLError(.badURL) @@ -74,7 +222,7 @@ class PocketBaseService { @MainActor func fetchEvents() async throws -> [Event] { - guard let url = URL(string: "\(baseURL)/api/collections/events/records?sort=start_time") else { + guard let url = URL(string: "\(baseURL)/events/records?sort=start_time") else { throw URLError(.badURL) } @@ -116,4 +264,39 @@ class PocketBaseService { throw error } } + + func fetchBulletins(activeOnly: Bool = false) async throws -> BulletinResponse { + let endpoint = "\(baseURL)/bulletins/records" + var components = URLComponents(string: endpoint)! + + var queryItems = [URLQueryItem]() + if activeOnly { + queryItems.append(URLQueryItem(name: "filter", value: "is_active=true")) + } + queryItems.append(URLQueryItem(name: "sort", value: "-date")) + components.queryItems = queryItems + + guard let url = components.url else { + throw URLError(.badURL) + } + + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + // Debug: Print the JSON response + if let jsonString = String(data: data, encoding: .utf8) { + print("Received JSON response: \(jsonString)") + } + + do { + return try decoder.decode(BulletinResponse.self, from: data) + } catch { + print("Failed to decode bulletins: \(error)") + throw error + } + } } \ No newline at end of file diff --git a/ViewModels/BulletinViewModel.swift b/ViewModels/BulletinViewModel.swift new file mode 100644 index 0000000..39f677a --- /dev/null +++ b/ViewModels/BulletinViewModel.swift @@ -0,0 +1,87 @@ +import Foundation +import SwiftUI + +@MainActor +class BulletinViewModel: ObservableObject { + @Published var latestBulletin: Bulletin? + @Published var bulletins: [Bulletin] = [] + @Published var isLoading = false + @Published var error: Error? + + private let pocketBaseService = PocketBaseService.shared + private var currentTask: Task? + + func loadLatestBulletin() async { + // Cancel any existing task + currentTask?.cancel() + + // Create new task + currentTask = Task { + guard !Task.isCancelled else { return } + + isLoading = true + error = nil + + do { + let response = try await pocketBaseService.fetchBulletins(activeOnly: true) + if !Task.isCancelled { + if let bulletin = response.items.first { + print("Loaded bulletin with ID: \(bulletin.id)") + print("PDF field value: \(bulletin.pdf ?? "nil")") + print("Generated PDF URL: \(bulletin.pdfUrl)") + latestBulletin = bulletin + } + } + } catch { + if !Task.isCancelled { + print("Error loading bulletin: \(error)") + self.error = error + } + } + + if !Task.isCancelled { + isLoading = false + } + } + + // Wait for the task to complete + await currentTask?.value + } + + @MainActor + func loadBulletins() async { + // Cancel any existing task + currentTask?.cancel() + + // Create new task + currentTask = Task { + guard !Task.isCancelled else { return } + + isLoading = true + error = nil + + do { + let response = try await PocketBaseService.shared.fetchBulletins(activeOnly: true) + if !Task.isCancelled { + self.bulletins = response.items + } + } catch { + if !Task.isCancelled { + print("Error loading bulletins: \(error)") + self.error = error + } + } + + if !Task.isCancelled { + isLoading = false + } + } + + // Wait for the task to complete + await currentTask?.value + } + + deinit { + currentTask?.cancel() + } +} diff --git a/Views/BulletinView.swift b/Views/BulletinView.swift index 1d6195b..7132d07 100644 --- a/Views/BulletinView.swift +++ b/Views/BulletinView.swift @@ -1,336 +1,573 @@ -@preconcurrency import SwiftUI -@preconcurrency import WebKit +import SwiftUI +import Foundation +import PDFKit -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 { +struct PDFViewer: UIViewRepresentable { let url: URL @Binding var isLoading: Bool + @Binding var error: Error? + @State private var downloadTask: Task? - func makeCoordinator() -> Coordinator { - Coordinator(self) + func makeUIView(context: Context) -> PDFView { + print("PDFViewer: Creating PDFView") + let pdfView = PDFView() + pdfView.autoScales = true + pdfView.displayMode = .singlePage + pdfView.displayDirection = .vertical + return pdfView } - func makeUIView(context: Context) -> WKWebView { - // Create configuration with script message handler - let configuration = WKWebViewConfiguration() - let contentController = WKUserContentController() + func updateUIView(_ uiView: PDFView, context: Context) { + print("PDFViewer: updateUIView called") + print("PDFViewer: URL = \(url)") - // Add hymn detection script - let hymnDetectionScript = """ - function detectAndModifyHymns() { - // Regular expression to match patterns like: - // - "Hymn XXX" or "Hymnal XXX" with optional quotes and title - // - "#XXX" with optional quotes and title - // - But NOT match when the number is followed by a colon (e.g., "10:45") - // - And NOT match when the number is actually part of a larger number - const hymnRegex = /(?:(hymn(?:al)?\\s+#?)|#)(\\d+)(?![\\d:\\.]|\\d*[apm])(?:\\s+["']([^"']+)["'])?/gi; + // Cancel any existing task + downloadTask?.cancel() + + // Create new task + downloadTask = Task { + print("PDFViewer: Starting download task") + isLoading = true + error = nil - // Extra check before creating links - function isValidHymnNumber(text, matchIndex, number) { - // Make sure this is not part of a time (e.g., "Hymn 10:45am") - const afterMatch = text.substring(matchIndex + number.length); - if (afterMatch.match(/^\\s*[:.]\\d|\\d*[apm]/)) { - return false; + do { + print("PDFViewer: Downloading PDF data...") + var request = URLRequest(url: url) + request.timeoutInterval = 30 // 30 second timeout + request.setValue("application/pdf", forHTTPHeaderField: "Accept") + request.setValue("application/pdf", forHTTPHeaderField: "Content-Type") + + // Add authentication headers + if let token = UserDefaults.standard.string(forKey: "authToken") { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - return true; - } - - // Function to replace text with a styled link - function replaceWithLink(node) { - if (node.nodeType === 3) { - // Text node - const content = node.textContent; - if (hymnRegex.test(content)) { - // Reset regex lastIndex - hymnRegex.lastIndex = 0; - - // Create a temporary element - const span = document.createElement('span'); - let lastIndex = 0; - let match; - - // Find all matches and replace them with links - while ((match = hymnRegex.exec(content)) !== null) { - // Add text before the match - if (match.index > lastIndex) { - span.appendChild(document.createTextNode(content.substring(lastIndex, match.index))); - } - - // Get the hymn number - const hymnNumber = match[2]; - - // Extra validation to ensure this isn't part of a time - const prefixLength = match[0].length - hymnNumber.length; - const numberStartIndex = match.index + prefixLength; - - if (!isValidHymnNumber(content, numberStartIndex, hymnNumber)) { - // Just add the original text if it's not a valid hymn reference - span.appendChild(document.createTextNode(match[0])); - } else { - // Create link element for valid hymn numbers - const hymnTitle = match[3] ? ': ' + match[3] : ''; - const link = document.createElement('a'); - link.textContent = match[0]; - link.href = 'javascript:void(0)'; - link.className = 'hymn-link'; - link.setAttribute('data-hymn-number', hymnNumber); - link.style.color = '#0070c9'; - link.style.textDecoration = 'underline'; - link.style.fontWeight = 'bold'; - link.onclick = function(e) { - e.preventDefault(); - window.webkit.messageHandlers.hymnHandler.postMessage({ number: hymnNumber }); - }; - - span.appendChild(link); - } - - lastIndex = match.index + match[0].length; - } - - // Add any remaining text - if (lastIndex < content.length) { - span.appendChild(document.createTextNode(content.substring(lastIndex))); - } - - // Replace the original node with our span containing links - if (span.childNodes.length > 0) { - node.parentNode.replaceChild(span, node); - } - } - } else if (node.nodeType === 1 && node.nodeName !== 'SCRIPT' && node.nodeName !== 'STYLE' && node.nodeName !== 'A') { - // Element node, not a script or style tag or already a link - Array.from(node.childNodes).forEach(child => replaceWithLink(child)); - } - } - - // Process the document body - replaceWithLink(document.body); - - console.log('Hymn detection script executed'); - } - - // Call the function after page has loaded and whenever content changes - detectAndModifyHymns(); - - // Use a MutationObserver to detect DOM changes and reapply the links - const observer = new MutationObserver(mutations => { - detectAndModifyHymns(); - }); - observer.observe(document.body, { childList: true, subtree: true }); - """ - - let userScript = WKUserScript( - source: hymnDetectionScript, - injectionTime: .atDocumentEnd, - forMainFrameOnly: false - ) - - contentController.addUserScript(userScript) - contentController.add(context.coordinator, name: "hymnHandler") - configuration.userContentController = contentController - - // Create the web view with our configuration - 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, WKScriptMessageHandler { - var parent: WebViewWithRefresh - - init(_ parent: WebViewWithRefresh) { - self.parent = parent - } - - // Handle messages from JavaScript - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - if message.name == "hymnHandler" { - guard let body = message.body as? [String: Any], - let hymnNumberString = body["number"] as? String, - let hymnNumber = Int(hymnNumberString) else { - print("❌ Invalid hymn number received") + + print("PDFViewer: Making request with headers: \(request.allHTTPHeaderFields ?? [:])") + + let (data, response) = try await URLSession.shared.data(for: request) + + // Check if task was cancelled + if Task.isCancelled { + print("PDFViewer: Task was cancelled, stopping download") return } - print("🎵 Opening hymn #\(hymnNumber)") - AppAvailabilityService.shared.openHymnByNumber(hymnNumber) + print("PDFViewer: Downloaded \(data.count) bytes") + + guard let httpResponse = response as? HTTPURLResponse else { + print("PDFViewer: Invalid response type") + throw URLError(.badServerResponse) + } + + print("PDFViewer: HTTP Status Code: \(httpResponse.statusCode)") + print("PDFViewer: Response headers: \(httpResponse.allHeaderFields)") + + guard httpResponse.statusCode == 200 else { + print("PDFViewer: Bad HTTP status code: \(httpResponse.statusCode)") + if let errorString = String(data: data, encoding: .utf8) { + print("PDFViewer: Error response: \(errorString)") + } + throw URLError(.badServerResponse) + } + + // Check if task was cancelled again before processing data + if Task.isCancelled { + print("PDFViewer: Task was cancelled before processing data") + return + } + + // Create PDF document from data + print("PDFViewer: Creating PDF document from data...") + if let document = PDFDocument(data: data) { + print("PDFViewer: PDF document created successfully") + print("PDFViewer: Number of pages: \(document.pageCount)") + + // Final cancellation check before updating UI + if Task.isCancelled { + print("PDFViewer: Task was cancelled before updating UI") + return + } + + await MainActor.run { + uiView.document = document + isLoading = false + print("PDFViewer: PDF document set to view") + } + } else { + print("PDFViewer: Failed to create PDF document from data") + print("PDFViewer: Data size: \(data.count) bytes") + print("PDFViewer: First few bytes: \(data.prefix(16).map { String(format: "%02x", $0) }.joined())") + throw URLError(.cannotDecodeContentData) + } + } catch { + // Only show error if it's not a cancellation + if !Task.isCancelled { + print("PDFViewer: Error loading PDF: \(error)") + print("PDFViewer: Error description: \(error.localizedDescription)") + if let decodingError = error as? DecodingError { + print("PDFViewer: Decoding error details: \(decodingError)") + } + if let urlError = error as? URLError { + print("PDFViewer: URL Error: \(urlError)") + print("PDFViewer: URL Error Code: \(urlError.code)") + print("PDFViewer: URL Error Description: \(urlError.localizedDescription)") + } + await MainActor.run { + self.error = error + isLoading = false + } + } else { + print("PDFViewer: Task was cancelled, ignoring error") + await MainActor.run { + isLoading = false + } + } } } - @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") + // Wait for the task to complete + Task { + await downloadTask?.value + } + } + + static func dismantleUIView(_ uiView: PDFView, coordinator: ()) { + print("PDFViewer: Dismantling view") + } +} + +struct BulletinListView: View { + @StateObject private var viewModel = BulletinViewModel() + + var body: some View { + NavigationView { + Group { + if viewModel.isLoading && viewModel.bulletins.isEmpty { + ProgressView() + } else if let error = viewModel.error { + VStack { + Text("Error loading bulletins") + .font(.headline) + .foregroundColor(.red) + Text(error.localizedDescription) + .font(.subheadline) + .foregroundColor(.secondary) + Button("Retry") { + Task { + await viewModel.loadBulletins() + } + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding() + } else if viewModel.bulletins.isEmpty { + VStack(spacing: 16) { + Image(systemName: "doc.text") + .font(.system(size: 50)) + .foregroundColor(.secondary) + Text("No Bulletins Available") + .font(.headline) + Text("Check back later for bulletins.") + .font(.subheadline) + .foregroundColor(.secondary) + Button("Refresh") { + Task { + await viewModel.loadBulletins() + } + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding() + } else { + List { + ForEach(viewModel.bulletins) { bulletin in + NavigationLink(destination: BulletinDetailView(bulletin: bulletin)) { + VStack(alignment: .leading, spacing: 4) { + Text(bulletin.date.formatted(date: .long, time: .omitted)) + .font(.headline) + Text(bulletin.title) + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + } + } + .refreshable { + await viewModel.loadBulletins() + } + } + } + .navigationTitle("Church Bulletins") + .task { + if viewModel.bulletins.isEmpty { + await viewModel.loadBulletins() + } + } + } + } +} + +struct BulletinDetailView: View { + let bulletin: Bulletin + + var body: some View { + ScrollView { + VStack(spacing: 24) { + // Header + VStack(spacing: 8) { + Text(bulletin.date.formatted(date: .long, time: .omitted)) + .font(.subheadline) + .foregroundColor(.secondary) - // 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; + Text(bulletin.title) + .font(.title) + .fontWeight(.bold) + .multilineTextAlignment(.center) + } + .padding(.vertical, 16) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 15) + .fill(Color(.systemBackground)) + .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 5) + ) + .padding(.horizontal) + + // PDF Download Button + if !bulletin.pdfUrl.isEmpty { + if let url = URL(string: bulletin.pdfUrl) { + Button(action: { + UIApplication.shared.open(url) + }) { + HStack { + Image(systemName: "doc.fill") + .font(.title3) + Text("View PDF") + .font(.headline) + } + .frame(maxWidth: .infinity) + .padding() + .background( + LinearGradient( + gradient: Gradient(colors: [Color.blue, Color.blue.opacity(0.8)]), + startPoint: .leading, + endPoint: .trailing + ) + ) + .foregroundColor(.white) + .cornerRadius(12) + .shadow(color: .blue.opacity(0.3), radius: 8, x: 0, y: 4) + } + .padding(.horizontal) + .padding(.bottom, 16) + } + } + + // Content + BulletinContentView(bulletin: bulletin) + .padding() + .background( + RoundedRectangle(cornerRadius: 15) + .fill(Color(.systemBackground)) + .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 5) + ) + .padding(.horizontal) + } + .padding(.vertical) + } + .background(Color(.systemGroupedBackground)) + } +} + +// Update the main BulletinView to use BulletinListView +struct BulletinView: View { + var body: some View { + BulletinListView() + } +} + +struct BulletinContentView: View { + let bulletin: Bulletin + + // Tuple to represent processed content segments + typealias ContentSegment = (id: UUID, text: String, type: ContentType, reference: String?) + + enum ContentType { + case text + case hymn(number: Int) + case bibleVerse + case sectionHeader + } + + private let sectionOrder = [ + ("Sabbath School", \Bulletin.sabbathSchool), + ("Divine Worship", \Bulletin.divineWorship), + ("Scripture Reading", \Bulletin.scriptureReading), + ("Sunset", \Bulletin.sunset) + ] + + private func cleanHTML(_ text: String) -> String { + // Remove HTML tags + var cleaned = text.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + + // Replace common HTML entities + let entities = [ + "&": "&", + "<": "<", + ">": ">", + """: "\"", + "'": "'", + "'": "'", + " ": " ", + "–": "–", + "—": "—", + "•": "•", + "æ": "æ", + "\\u003c": "<", + "\\u003e": ">", + "\\r\\n": " " + ] + + for (entity, replacement) in entities { + cleaned = cleaned.replacingOccurrences(of: entity, with: replacement) + } + + // Clean up whitespace and normalize spaces + cleaned = cleaned.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + cleaned = cleaned.replacingOccurrences(of: #"(\d+)\s*:\s*(\d+)"#, with: "$1:$2", options: .regularExpression) + cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + + return cleaned + } + + private func processLine(_ line: String) -> [ContentSegment] { + // Clean HTML first + let cleanedLine = cleanHTML(line) + var segments: [ContentSegment] = [] + let nsLine = cleanedLine as NSString + + // Match hymn numbers with surrounding text + let hymnPattern = #"(?:Hymn(?:al)?\s+(?:#\s*)?|#\s*)(\d+)(?:\s+["""]([^"""]*)[""])?.*"# + let hymnRegex = try! NSRegularExpression(pattern: hymnPattern, options: [.caseInsensitive]) + let hymnMatches = hymnRegex.matches(in: cleanedLine, range: NSRange(location: 0, length: nsLine.length)) + + // Match Bible verses (simplified pattern) + let versePattern = #"(?:^|\s|[,;])\s*(?:(?:1|2|3|I|II|III|First|Second|Third)\s+)?(?:Genesis|Exodus|Leviticus|Numbers|Deuteronomy|Joshua|Judges|Ruth|(?:1st|2nd|1|2)\s*Samuel|(?:1st|2nd|1|2)\s*Kings|(?:1st|2nd|1|2)\s*Chronicles|Ezra|Nehemiah|Esther|Job|Psalms?|Proverbs|Ecclesiastes|Song\s+of\s+Solomon|Isaiah|Jeremiah|Lamentations|Ezekiel|Daniel|Hosea|Joel|Amos|Obadiah|Jonah|Micah|Nahum|Habakkuk|Zephaniah|Haggai|Zechariah|Malachi|Matthew|Mark|Luke|John|Acts|Romans|(?:1st|2nd|1|2)\s*Corinthians|Galatians|Ephesians|Philippians|Colossians|(?:1st|2nd|1|2)\s*Thessalonians|(?:1st|2nd|1|2)\s*Timothy|Titus|Philemon|Hebrews|James|(?:1st|2nd|1|2)\s*Peter|(?:1st|2nd|3rd|1|2|3)\s*John|Jude|Revelation)s?\s+\d+(?::\d+(?:-\d+)?)?(?:\s*,\s*\d+(?::\d+(?:-\d+)?)?)*"# + let verseRegex = try! NSRegularExpression(pattern: versePattern, options: [.caseInsensitive]) + let verseMatches = verseRegex.matches(in: cleanedLine, range: NSRange(location: 0, length: nsLine.length)) + + if !hymnMatches.isEmpty { + var lastIndex = 0 + + for match in hymnMatches { + // Add text before hymn + if match.range.location > lastIndex { + let text = nsLine.substring(with: NSRange(location: lastIndex, length: match.range.location - lastIndex)) + if !text.isEmpty { + segments.append((id: UUID(), text: text.trimmingCharacters(in: .whitespaces), type: .text, reference: nil)) + } + } + + // Add entire hymn line + let hymnNumber = Int(nsLine.substring(with: match.range(at: 1)))! + let fullHymnText = nsLine.substring(with: match.range) + segments.append((id: UUID(), text: fullHymnText, type: .hymn(number: hymnNumber), reference: nil)) + + lastIndex = match.range.location + match.range.length + } + + // Add remaining text + if lastIndex < nsLine.length { + let text = nsLine.substring(from: lastIndex) + if !text.isEmpty { + segments.append((id: UUID(), text: text.trimmingCharacters(in: .whitespaces), type: .text, reference: nil)) + } + } + } else if !verseMatches.isEmpty { + var lastIndex = 0 + + for match in verseMatches { + // Add text before verse + if match.range.location > lastIndex { + let text = nsLine.substring(with: NSRange(location: lastIndex, length: match.range.location - lastIndex)) + if !text.isEmpty { + segments.append((id: UUID(), text: text.trimmingCharacters(in: .whitespaces), type: .text, reference: nil)) + } + } + + // Add verse with full range, keeping KJV in display text + let verseText = nsLine.substring(with: match.range) + .trimmingCharacters(in: .whitespaces) + + segments.append((id: UUID(), text: verseText, type: .bibleVerse, reference: verseText)) + + lastIndex = match.range.location + match.range.length + } + + // Add remaining text + if lastIndex < nsLine.length { + let text = nsLine.substring(from: lastIndex) + if !text.isEmpty { + segments.append((id: UUID(), text: text.trimmingCharacters(in: .whitespaces), type: .text, reference: nil)) + } + } + } else { + // Regular text + segments.append((id: UUID(), text: cleanedLine, type: .text, reference: nil)) + } + + return segments + } + + private func formatBibleVerse(_ verse: String) -> String { + // Strip out translation references (e.g., "KJV") + let cleanVerse = verse.replacingOccurrences(of: #"(?:\s+(?:KJV|NIV|ESV|NKJV|NLT|RSV|ASV|CEV|GNT|MSG|NET|NRSV|WEB|YLT|DBY|WNT|BBE|DARBY|WBS|KJ21|AKJV|ASV1901|CEB|CJB|CSB|ERV|EHV|EXB|GNV|GW|ICB|ISV|JUB|LEB|MEV|MOUNCE|NOG|OJB|RGT|TLV|VOICE|WYC|WYNN|YLT1898))"#, with: "", options: [.regularExpression, .caseInsensitive]) + + // 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 = cleanVerse.components(separatedBy: " ") + guard components.count >= 2 else { return cleanVerse.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 cleanVerse.lowercased() } + + // Format chapter and verse + let reference = remainingComponents.joined(separator: "") + .replacingOccurrences(of: ":", with: ".") + .replacingOccurrences(of: "-", with: "-") + + return "\(bookCode).\(reference)" + } + + var body: some View { + VStack(alignment: .center, spacing: 24) { + ForEach(sectionOrder, id: \.0) { (title, keyPath) in + let content = bulletin[keyPath: keyPath] + if !content.isEmpty { + VStack(alignment: .center, spacing: 16) { + Text(title) + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 4) + + VStack(alignment: .center, spacing: 12) { + ForEach(Array(zip(content.components(separatedBy: .newlines).indices, content.components(separatedBy: .newlines))), id: \.0) { index, line in + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + if !trimmedLine.isEmpty { + HStack(alignment: .center, spacing: 4) { + ForEach(processLine(trimmedLine), id: \.id) { segment in + switch segment.type { + case .text: + Text(segment.text) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + case .hymn(let number): + Button(action: { + AppAvailabilityService.shared.openHymnByNumber(number) + }) { + HStack(spacing: 6) { + Image(systemName: "music.note") + .foregroundColor(.blue) + Text(segment.text) + .foregroundColor(.blue) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.blue.opacity(0.1)) + ) + } + case .bibleVerse: + if let reference = segment.reference { + Button(action: { + let formattedVerse = formatBibleVerse(reference) + if let url = URL(string: "https://www.bible.com/bible/1/\(formattedVerse)") { + UIApplication.shared.open(url) + } + }) { + HStack(spacing: 6) { + Image(systemName: "book.fill") + .foregroundColor(.blue) + Text(segment.text) + .foregroundColor(.blue) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.blue.opacity(0.1)) + ) + } + } + case .sectionHeader: + Text(segment.text) + .font(.headline) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + } + } } + .frame(maxWidth: .infinity, alignment: .center) } } - console.log('No back button found'); - return null; } - - var backButton = findBackButton(); - if (backButton) { - backButton.click(); - true; - } else { - false; - } - """ + } + .padding(.vertical, 12) - 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") - } + if title != sectionOrder.last?.0 { + Divider() + .padding(.vertical, 8) } } } } - - @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() - - // Execute the hymn detection script again after the page loads - let rerunScript = "detectAndModifyHymns();" - webView.evaluateJavaScript(rerunScript) { _, error in - if let error = error { - print("❌ Error running hymn detection script: \(error.localizedDescription)") - } else { - print("✅ Hymn detection script executed after page load") - } - } - } - - 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) - } + .frame(maxWidth: .infinity) } } #Preview { - NavigationStack { - BulletinView() - } + BulletinView() } diff --git a/Views/BulletinViews.swift b/Views/BulletinViews.swift new file mode 100644 index 0000000..8a7b2e2 --- /dev/null +++ b/Views/BulletinViews.swift @@ -0,0 +1,192 @@ +import SwiftUI + +struct BulletinListView: View { + @StateObject private var viewModel = BulletinViewModel() + + var body: some View { + NavigationView { + Group { + if viewModel.isLoading { + ProgressView() + } else if let error = viewModel.error { + VStack { + Text("Error loading bulletins") + .font(.headline) + .foregroundColor(.red) + Text(error.localizedDescription) + .font(.subheadline) + .foregroundColor(.secondary) + Button("Retry") { + Task { + await viewModel.loadBulletins() + } + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding() + } else if viewModel.bulletins.isEmpty { + VStack { + Text("No Bulletins") + .font(.headline) + Text("No bulletins are available at this time.") + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding() + } else { + List(viewModel.bulletins) { bulletin in + NavigationLink(destination: BulletinDetailView(bulletin: bulletin)) { + BulletinRowView(bulletin: bulletin) + } + } + } + } + .navigationTitle("Church Bulletins") + .task { + await viewModel.loadBulletins() + } + } + } +} + +struct BulletinDetailView: View { + let bulletin: Bulletin + @Environment(\.dismiss) private var dismiss + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Header + VStack(alignment: .leading, spacing: 8) { + Text(bulletin.date.formatted(date: .long, time: .omitted)) + .font(.subheadline) + .foregroundColor(.secondary) + + Text(bulletin.title) + .font(.title) + .fontWeight(.bold) + } + + // PDF Download Button + if let pdfUrl = bulletin.pdfUrl, let url = URL(string: pdfUrl) { + Link(destination: url) { + Label("Download PDF", systemImage: "doc.fill") + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + } + + // Sections + ForEach(bulletin.sections, id: \.title) { section in + BulletinSectionView(section: section) + } + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } +} + +struct BulletinSectionView: View { + let section: BulletinSection + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(section.title) + .font(.headline) + + if section.title == "Scripture Reading" { + ScriptureReadingView(content: section.content) + } else { + BulletinContentText(content: section.content) + } + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(10) + .shadow(radius: 2) + } +} + +struct ScriptureReadingView: View { + let content: String + + var body: some View { + VStack(spacing: 8) { + ForEach(content.components(separatedBy: .newlines), id: \.self) { line in + if !line.isEmpty { + Text(line) + .font(.body) + .foregroundColor(line.contains("Acts") ? .primary : .secondary) + } + } + } + } +} + +struct BulletinContentText: View { + let content: String + + var formattedContent: [(label: String?, value: String)] { + content.components(separatedBy: .newlines) + .map { line -> (String?, String) in + let parts = line.split(separator: ":", maxSplits: 1).map(String.init) + if parts.count == 2 { + return (parts[0].trimmingCharacters(in: .whitespaces), + parts[1].trimmingCharacters(in: .whitespaces)) + } + return (nil, line) + } + .filter { !$0.1.isEmpty } + } + + var body: some View { + VStack(spacing: 12) { + ForEach(formattedContent, id: \.1) { item in + if let label = item.label { + VStack(spacing: 4) { + Text(label) + .font(.headline) + .foregroundColor(.primary) + Text(item.value) + .font(.body) + .foregroundColor(.secondary) + } + } else { + Text(item.value) + .font(.body) + .foregroundColor(.secondary) + } + } + } + } +} + +struct BulletinRowView: View { + let bulletin: Bulletin + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(bulletin.date.formatted(date: .long, time: .omitted)) + .font(.subheadline) + .foregroundColor(.secondary) + + Text(bulletin.title) + .font(.headline) + .lineLimit(2) + + if bulletin.pdfUrl != nil { + Label("PDF Available", systemImage: "doc.fill") + .font(.caption) + .foregroundColor(.blue) + } + } + .padding(.vertical, 8) + } +} \ No newline at end of file