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 = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
EA1C83A42D43EA4900D8B78F /* SermonBrowserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */; };
|
||||||
EA1C83A52D43EA4900D8B78F /* RTSDAApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */; };
|
EA1C83A52D43EA4900D8B78F /* RTSDAApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */; };
|
||||||
EA1C83A62D43EA4900D8B78F /* EventsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C838C2D43EA4900D8B78F /* EventsViewModel.swift */; };
|
EA1C83A62D43EA4900D8B78F /* EventsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C838C2D43EA4900D8B78F /* EventsViewModel.swift */; };
|
||||||
|
@ -49,6 +50,7 @@
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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>"; };
|
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>"; };
|
EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
@ -189,6 +191,7 @@
|
||||||
EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */,
|
EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */,
|
||||||
EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */,
|
EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */,
|
||||||
EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */,
|
EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */,
|
||||||
|
EA019ECE2D978167002FC58F /* BulletinViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = ViewModels;
|
path = ViewModels;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -365,6 +368,7 @@
|
||||||
EA1C83C22D43EA4900D8B78F /* MessagesViewModel.swift in Sources */,
|
EA1C83C22D43EA4900D8B78F /* MessagesViewModel.swift in Sources */,
|
||||||
EA1C83C32D43EA4900D8B78F /* OwnCastService.swift in Sources */,
|
EA1C83C32D43EA4900D8B78F /* OwnCastService.swift in Sources */,
|
||||||
EA1C83C42D43EA4900D8B78F /* SplashScreenView.swift in Sources */,
|
EA1C83C42D43EA4900D8B78F /* SplashScreenView.swift in Sources */,
|
||||||
|
EA019ECF2D978167002FC58F /* BulletinViewModel.swift in Sources */,
|
||||||
EA1C83C52D43EA4900D8B78F /* JellyfinPlayerView.swift in Sources */,
|
EA1C83C52D43EA4900D8B78F /* JellyfinPlayerView.swift in Sources */,
|
||||||
EA1C83C62D43EA4900D8B78F /* AppAvailabilityService.swift in Sources */,
|
EA1C83C62D43EA4900D8B78F /* AppAvailabilityService.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
@ -527,7 +531,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
@ -570,7 +574,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
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
|
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 {
|
class PocketBaseService {
|
||||||
static let shared = PocketBaseService()
|
static let shared = PocketBaseService()
|
||||||
private let baseURL = "https://pocketbase.rockvilletollandsda.church"
|
private let baseURL = "https://pocketbase.rockvilletollandsda.church/api/collections"
|
||||||
|
|
||||||
private init() {}
|
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 {
|
struct EventResponse: Codable {
|
||||||
let page: Int
|
let page: Int
|
||||||
let perPage: 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
|
@MainActor
|
||||||
func fetchConfig() async throws -> Config {
|
func fetchConfig() async throws -> Config {
|
||||||
let recordId = "nn753t8o2t1iupd"
|
let recordId = "nn753t8o2t1iupd"
|
||||||
let urlString = "\(baseURL)/api/collections/config/records/\(recordId)"
|
let urlString = "\(baseURL)/config/records/\(recordId)"
|
||||||
|
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
throw URLError(.badURL)
|
throw URLError(.badURL)
|
||||||
|
@ -74,7 +222,7 @@ class PocketBaseService {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func fetchEvents() async throws -> [Event] {
|
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)
|
throw URLError(.badURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -116,4 +264,39 @@ class PocketBaseService {
|
||||||
throw error
|
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
|
import SwiftUI
|
||||||
@preconcurrency import WebKit
|
import Foundation
|
||||||
|
import PDFKit
|
||||||
|
|
||||||
struct BulletinView: View {
|
struct PDFViewer: UIViewRepresentable {
|
||||||
@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 {
|
|
||||||
let url: URL
|
let url: URL
|
||||||
@Binding var isLoading: Bool
|
@Binding var isLoading: Bool
|
||||||
|
@Binding var error: Error?
|
||||||
|
@State private var downloadTask: Task<Void, Never>?
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeUIView(context: Context) -> PDFView {
|
||||||
Coordinator(self)
|
print("PDFViewer: Creating PDFView")
|
||||||
|
let pdfView = PDFView()
|
||||||
|
pdfView.autoScales = true
|
||||||
|
pdfView.displayMode = .singlePage
|
||||||
|
pdfView.displayDirection = .vertical
|
||||||
|
return pdfView
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func updateUIView(_ uiView: PDFView, context: Context) {
|
||||||
// Create configuration with script message handler
|
print("PDFViewer: updateUIView called")
|
||||||
let configuration = WKWebViewConfiguration()
|
print("PDFViewer: URL = \(url)")
|
||||||
let contentController = WKUserContentController()
|
|
||||||
|
|
||||||
// Add hymn detection script
|
// Cancel any existing task
|
||||||
let hymnDetectionScript = """
|
downloadTask?.cancel()
|
||||||
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;
|
|
||||||
|
|
||||||
// Extra check before creating links
|
// Create new task
|
||||||
function isValidHymnNumber(text, matchIndex, number) {
|
downloadTask = Task {
|
||||||
// Make sure this is not part of a time (e.g., "Hymn 10:45am")
|
print("PDFViewer: Starting download task")
|
||||||
const afterMatch = text.substring(matchIndex + number.length);
|
isLoading = true
|
||||||
if (afterMatch.match(/^\\s*[:.]\\d|\\d*[apm]/)) {
|
error = nil
|
||||||
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
|
print("PDFViewer: Making request with headers: \(request.allHTTPHeaderFields ?? [:])")
|
||||||
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
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
const span = document.createElement('span');
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
// Find all matches and replace them with links
|
// Check if task was cancelled
|
||||||
while ((match = hymnRegex.exec(content)) !== null) {
|
if Task.isCancelled {
|
||||||
// Add text before the match
|
print("PDFViewer: Task was cancelled, stopping download")
|
||||||
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")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
print("🎵 Opening hymn #\(hymnNumber)")
|
print("PDFViewer: Downloaded \(data.count) bytes")
|
||||||
AppAvailabilityService.shared.openHymnByNumber(hymnNumber)
|
|
||||||
|
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) {
|
// Wait for the task to complete
|
||||||
print("🔄 Swipe detected")
|
Task {
|
||||||
if gesture.state == .ended {
|
await downloadTask?.value
|
||||||
if let webView = gesture.view as? WKWebView {
|
}
|
||||||
print("📱 Attempting to trigger back action")
|
}
|
||||||
|
|
||||||
// JavaScript to click the Chakra UI back button
|
static func dismantleUIView(_ uiView: PDFView, coordinator: ()) {
|
||||||
let script = """
|
print("PDFViewer: Dismantling view")
|
||||||
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++) {
|
struct BulletinListView: View {
|
||||||
var buttons = document.querySelectorAll(selectors[i]);
|
@StateObject private var viewModel = BulletinViewModel()
|
||||||
for (var j = 0; j < buttons.length; j++) {
|
|
||||||
var button = buttons[j];
|
var body: some View {
|
||||||
// Check if it looks like a back button
|
NavigationView {
|
||||||
if (button.textContent.toLowerCase().includes('back') ||
|
Group {
|
||||||
button.getAttribute('aria-label')?.toLowerCase().includes('back') ||
|
if viewModel.isLoading && viewModel.bulletins.isEmpty {
|
||||||
button.innerHTML.toLowerCase().includes('back')) {
|
ProgressView()
|
||||||
console.log('Found back button:', button.outerHTML);
|
} else if let error = viewModel.error {
|
||||||
return button;
|
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)
|
||||||
|
|
||||||
|
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;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
var backButton = findBackButton();
|
if title != sectionOrder.last?.0 {
|
||||||
if (backButton) {
|
Divider()
|
||||||
backButton.click();
|
.padding(.vertical, 8)
|
||||||
true;
|
|
||||||
} else {
|
|
||||||
false;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
@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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#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