Version 1.2: Integrate new digital bulletin system with improved UI and Bible verse detection

This commit is contained in:
RTSDA 2025-03-28 23:00:00 -04:00
parent 01aecc4bb3
commit 7e6e0178eb
7 changed files with 1148 additions and 311 deletions

66
Models/Bulletin.swift Normal file
View file

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

View file

@ -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 = "<group>"; };
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 = "<group>"; };
EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
@ -189,6 +191,7 @@
EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */,
EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */,
EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */,
EA019ECE2D978167002FC58F /* BulletinViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@ -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;

View file

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

View file

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

View file

@ -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<Void, Never>?
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()
}
}

View file

@ -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<Void, Never>?
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 = [
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&apos;": "'",
"&#39;": "'",
"&nbsp;": " ",
"&ndash;": "",
"&mdash;": "",
"&bull;": "",
"&aelig;": "æ",
"\\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()
}

192
Views/BulletinViews.swift Normal file
View file

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