Version 1.2: Integrate new digital bulletin system with improved UI and Bible verse detection
This commit is contained in:
parent
01aecc4bb3
commit
7e6e0178eb
66
Models/Bulletin.swift
Normal file
66
Models/Bulletin.swift
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
68
Services/BulletinService.swift
Normal file
68
Services/BulletinService.swift
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
87
ViewModels/BulletinViewModel.swift
Normal file
87
ViewModels/BulletinViewModel.swift
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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 = [
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": "\"",
|
||||
"'": "'",
|
||||
"'": "'",
|
||||
" ": " ",
|
||||
"–": "–",
|
||||
"—": "—",
|
||||
"•": "•",
|
||||
"æ": "æ",
|
||||
"\\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
192
Views/BulletinViews.swift
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue