824 lines
34 KiB
Swift
824 lines
34 KiB
Swift
import SwiftUI
|
||
import Foundation
|
||
import PDFKit
|
||
|
||
struct PDFViewer: UIViewRepresentable {
|
||
let url: URL
|
||
@Binding var isLoading: Bool
|
||
@Binding var error: Error?
|
||
@Binding var hasInteracted: Bool
|
||
@State private var downloadTask: Task<Void, Never>?
|
||
@State private var documentTask: Task<Void, Never>?
|
||
@State private var pageCount: Int = 0
|
||
@State private var currentPage: Int = 1
|
||
@State private var pdfData: Data?
|
||
|
||
func makeUIView(context: Context) -> PDFView {
|
||
let pdfView = PDFView()
|
||
pdfView.autoScales = true
|
||
pdfView.displayMode = .twoUpContinuous
|
||
pdfView.displayDirection = .horizontal
|
||
pdfView.backgroundColor = .systemBackground
|
||
pdfView.usePageViewController(true)
|
||
pdfView.delegate = context.coordinator
|
||
return pdfView
|
||
}
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(self)
|
||
}
|
||
|
||
func updateUIView(_ uiView: PDFView, context: Context) {
|
||
// Only start a new download if we don't have the data yet
|
||
if pdfData == nil {
|
||
Task { @MainActor in
|
||
await startDownload(for: uiView)
|
||
}
|
||
} else if uiView.document == nil && documentTask == nil {
|
||
Task { @MainActor in
|
||
await createDocument(for: uiView)
|
||
}
|
||
}
|
||
}
|
||
|
||
class Coordinator: NSObject, PDFViewDelegate {
|
||
var parent: PDFViewer
|
||
|
||
init(_ parent: PDFViewer) {
|
||
self.parent = parent
|
||
}
|
||
|
||
func pdfViewPageChanged(_ notification: Notification) {
|
||
if !parent.hasInteracted {
|
||
parent.hasInteracted = true
|
||
}
|
||
}
|
||
}
|
||
|
||
private func createDocument(for pdfView: PDFView) async {
|
||
documentTask?.cancel()
|
||
|
||
documentTask = Task {
|
||
do {
|
||
guard let data = pdfData else { return }
|
||
let document = try await createPDFDocument(from: data)
|
||
|
||
if !Task.isCancelled {
|
||
await MainActor.run {
|
||
pdfView.document = document
|
||
pageCount = document.pageCount
|
||
isLoading = false
|
||
}
|
||
}
|
||
} catch {
|
||
if !Task.isCancelled {
|
||
await MainActor.run {
|
||
self.error = error
|
||
isLoading = false
|
||
}
|
||
}
|
||
}
|
||
await MainActor.run {
|
||
documentTask = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
private func startDownload(for pdfView: PDFView) async {
|
||
// Cancel any existing task
|
||
downloadTask?.cancel()
|
||
|
||
// Create new task
|
||
downloadTask = Task {
|
||
await MainActor.run {
|
||
isLoading = true
|
||
error = nil
|
||
}
|
||
|
||
do {
|
||
// Download PDF data
|
||
let (data, _) = try await downloadPDFData()
|
||
|
||
// Check if task was cancelled
|
||
if Task.isCancelled { return }
|
||
|
||
// Store the data
|
||
await MainActor.run {
|
||
self.pdfData = data
|
||
}
|
||
|
||
} catch {
|
||
if !Task.isCancelled {
|
||
await MainActor.run {
|
||
self.error = error
|
||
isLoading = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func createPDFDocument(from data: Data) async throws -> PDFDocument {
|
||
return try await Task.detached {
|
||
guard let document = PDFDocument(data: data) else {
|
||
throw URLError(.cannotDecodeContentData)
|
||
}
|
||
return document
|
||
}.value
|
||
}
|
||
|
||
private func downloadPDFData() async throws -> (Data, URLResponse) {
|
||
var request = URLRequest(url: url)
|
||
request.timeoutInterval = 30
|
||
request.setValue("application/pdf", forHTTPHeaderField: "Accept")
|
||
request.setValue("application/pdf", forHTTPHeaderField: "Content-Type")
|
||
|
||
if let token = UserDefaults.standard.string(forKey: "authToken") {
|
||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||
}
|
||
|
||
return try await URLSession.shared.data(for: request)
|
||
}
|
||
|
||
static func dismantleUIView(_ uiView: PDFView, coordinator: ()) {
|
||
uiView.document = nil
|
||
}
|
||
}
|
||
|
||
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
|
||
@State private var isLoading = false
|
||
@State private var error: Error?
|
||
@State private var showPDFViewer = false
|
||
@State private var showScrollIndicator = true
|
||
@State private var hasInteracted = false
|
||
|
||
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 Button
|
||
if bulletin.pdf != nil {
|
||
Button(action: {
|
||
showPDFViewer = true
|
||
showScrollIndicator = true
|
||
hasInteracted = false
|
||
}) {
|
||
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))
|
||
.sheet(isPresented: $showPDFViewer) {
|
||
if let url = URL(string: bulletin.pdfUrl) {
|
||
NavigationStack {
|
||
ZStack {
|
||
PDFViewer(url: url, isLoading: $isLoading, error: $error, hasInteracted: $hasInteracted)
|
||
.ignoresSafeArea()
|
||
|
||
if isLoading {
|
||
ProgressView()
|
||
.progressViewStyle(.circular)
|
||
.scaleEffect(1.5)
|
||
.tint(.white)
|
||
}
|
||
|
||
if let error = error {
|
||
VStack(spacing: 16) {
|
||
Text("Error loading PDF")
|
||
.font(.headline)
|
||
.foregroundColor(.red)
|
||
Text(error.localizedDescription)
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
Button("Try Again") {
|
||
self.error = nil
|
||
}
|
||
.buttonStyle(.bordered)
|
||
}
|
||
.padding()
|
||
.background(Color(.systemBackground))
|
||
.cornerRadius(10)
|
||
}
|
||
|
||
if showScrollIndicator && !hasInteracted {
|
||
VStack {
|
||
Spacer()
|
||
HStack {
|
||
Spacer()
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "arrow.left.and.right")
|
||
.font(.title)
|
||
.foregroundColor(.white)
|
||
Text("Swipe to navigate")
|
||
.font(.caption)
|
||
.foregroundColor(.white)
|
||
Button("Got it") {
|
||
withAnimation {
|
||
showScrollIndicator = false
|
||
hasInteracted = true
|
||
}
|
||
}
|
||
.font(.caption)
|
||
.foregroundColor(.white)
|
||
.padding(.top, 4)
|
||
}
|
||
.padding()
|
||
.background(Color.black.opacity(0.7))
|
||
.cornerRadius(10)
|
||
.padding()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("PDF Viewer")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .navigationBarTrailing) {
|
||
Button("Done") {
|
||
showPDFViewer = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 responsiveReading(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
|
||
|
||
// Check for section headers
|
||
if let headerSegment = processHeader(cleanedLine, nsLine) {
|
||
segments.append(headerSegment)
|
||
return segments
|
||
}
|
||
|
||
// Process hymn numbers
|
||
if let hymnSegments = processHymns(cleanedLine, nsLine) {
|
||
segments.append(contentsOf: hymnSegments)
|
||
return segments
|
||
}
|
||
|
||
// Process responsive readings
|
||
if let readingSegments = processResponsiveReadings(cleanedLine, nsLine) {
|
||
segments.append(contentsOf: readingSegments)
|
||
return segments
|
||
}
|
||
|
||
// Process Bible verses
|
||
if let verseSegments = processBibleVerses(cleanedLine, nsLine) {
|
||
segments.append(contentsOf: verseSegments)
|
||
return segments
|
||
}
|
||
|
||
// If no special processing was done, add as regular text
|
||
segments.append((id: UUID(), text: cleanedLine, type: .text, reference: nil))
|
||
return segments
|
||
}
|
||
|
||
private func processHeader(_ line: String, _ nsLine: NSString) -> ContentSegment? {
|
||
let headerPatterns = [
|
||
// Sabbath School headers
|
||
#"^(Sabbath School):?"#,
|
||
#"^(Song Service):?"#,
|
||
#"^(Leadership):?"#,
|
||
#"^(Lesson Study):?"#,
|
||
#"^(Mission Story):?"#,
|
||
#"^(Welcome):?"#,
|
||
#"^(Opening Song):?"#,
|
||
#"^(Opening Prayer):?"#,
|
||
#"^(Mission Spotlight):?"#,
|
||
#"^(Bible Study):?"#,
|
||
#"^(Closing Song):?"#,
|
||
#"^(Closing Prayer):?"#,
|
||
// Divine Worship headers
|
||
#"^(Announcements):?"#,
|
||
#"^(Call To Worship):?"#,
|
||
#"^(Opening Hymn):?"#,
|
||
#"^(Prayer & Praises):?"#,
|
||
#"^(Prayer Song):?"#,
|
||
#"^(Offering):?"#,
|
||
#"^(Children's Story):?"#,
|
||
#"^(Special Music):?"#,
|
||
#"^(Scripture Reading):?"#,
|
||
#"^(Sermon):?"#,
|
||
#"^(Closing Hymn):?"#,
|
||
#"^(Benediction):?"#
|
||
]
|
||
|
||
for pattern in headerPatterns {
|
||
let headerRegex = try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
|
||
let headerMatches = headerRegex.matches(in: line, range: NSRange(location: 0, length: nsLine.length))
|
||
|
||
if !headerMatches.isEmpty {
|
||
let headerText = nsLine.substring(with: headerMatches[0].range(at: 1))
|
||
.trimmingCharacters(in: .whitespaces)
|
||
return (id: UUID(), text: headerText, type: .sectionHeader, reference: nil)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
private func processHymns(_ line: String, _ nsLine: NSString) -> [ContentSegment]? {
|
||
let hymnPattern = #"(?:Hymn(?:al)?\s+(?:#\s*)?|#\s*)(\d+)(?:\s+["""]([^"""]*)[""])?.*"#
|
||
let hymnRegex = try! NSRegularExpression(pattern: hymnPattern, options: [.caseInsensitive])
|
||
let hymnMatches = hymnRegex.matches(in: line, range: NSRange(location: 0, length: nsLine.length))
|
||
|
||
if hymnMatches.isEmpty { return nil }
|
||
|
||
var segments: [ContentSegment] = []
|
||
var lastIndex = 0
|
||
|
||
for match in hymnMatches {
|
||
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))
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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))
|
||
}
|
||
}
|
||
|
||
return segments
|
||
}
|
||
|
||
private func processResponsiveReadings(_ line: String, _ nsLine: NSString) -> [ContentSegment]? {
|
||
let responsivePattern = #"(?:Responsive\s+Reading\s+(?:#\s*)?|#\s*)(\d+)(?:\s+["""]([^"""]*)[""])?.*"#
|
||
let responsiveRegex = try! NSRegularExpression(pattern: responsivePattern, options: [.caseInsensitive])
|
||
let responsiveMatches = responsiveRegex.matches(in: line, range: NSRange(location: 0, length: nsLine.length))
|
||
|
||
if responsiveMatches.isEmpty { return nil }
|
||
|
||
var segments: [ContentSegment] = []
|
||
var lastIndex = 0
|
||
|
||
for match in responsiveMatches {
|
||
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))
|
||
}
|
||
}
|
||
|
||
let readingNumber = Int(nsLine.substring(with: match.range(at: 1)))!
|
||
let fullReadingText = nsLine.substring(with: match.range)
|
||
segments.append((id: UUID(), text: fullReadingText, type: .responsiveReading(number: readingNumber), reference: nil))
|
||
|
||
lastIndex = match.range.location + match.range.length
|
||
}
|
||
|
||
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))
|
||
}
|
||
}
|
||
|
||
return segments
|
||
}
|
||
|
||
private func processBibleVerses(_ line: String, _ nsLine: NSString) -> [ContentSegment]? {
|
||
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: line, range: NSRange(location: 0, length: nsLine.length))
|
||
|
||
if verseMatches.isEmpty { return nil }
|
||
|
||
var segments: [ContentSegment] = []
|
||
var lastIndex = 0
|
||
|
||
for match in verseMatches {
|
||
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))
|
||
}
|
||
}
|
||
|
||
let verseText = nsLine.substring(with: match.range)
|
||
.trimmingCharacters(in: .whitespaces)
|
||
|
||
// Extract the complete verse reference including chapter and verse
|
||
let referencePattern = #"(?:(?: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 referenceRegex = try! NSRegularExpression(pattern: referencePattern, options: [.caseInsensitive])
|
||
if let referenceMatch = referenceRegex.firstMatch(in: verseText, range: NSRange(location: 0, length: verseText.count)) {
|
||
let reference = (verseText as NSString).substring(with: referenceMatch.range)
|
||
segments.append((id: UUID(), text: verseText, type: .bibleVerse, reference: reference))
|
||
}
|
||
|
||
lastIndex = match.range.location + match.range.length
|
||
}
|
||
|
||
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))
|
||
}
|
||
}
|
||
|
||
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])
|
||
.trimmingCharacters(in: .whitespaces)
|
||
|
||
// Convert "Romans 4:11" to "rom.4.11" for Bible.com links
|
||
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: "-")
|
||
.replacingOccurrences(of: ",", with: ".")
|
||
.replacingOccurrences(of: " ", with: "")
|
||
|
||
return "\(bookCode).\(reference)"
|
||
}
|
||
|
||
private func renderTextSegment(_ segment: ContentSegment) -> some View {
|
||
Text(segment.text)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.multilineTextAlignment(.center)
|
||
.foregroundColor(.primary)
|
||
}
|
||
|
||
private func renderHymnSegment(_ segment: ContentSegment, number: Int) -> some View {
|
||
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))
|
||
)
|
||
}
|
||
}
|
||
|
||
private func renderResponsiveReadingSegment(_ segment: ContentSegment, number: Int) -> some View {
|
||
Button(action: {
|
||
AppAvailabilityService.shared.openResponsiveReadingByNumber(number)
|
||
}) {
|
||
HStack {
|
||
Image(systemName: "book")
|
||
.foregroundColor(.blue)
|
||
Text("Responsive Reading #\(number)")
|
||
.foregroundColor(.blue)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func renderBibleVerseSegment(_ segment: ContentSegment, reference: String) -> some View {
|
||
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))
|
||
)
|
||
}
|
||
}
|
||
|
||
private func renderSectionHeaderSegment(_ segment: ContentSegment) -> some View {
|
||
Text(segment.text)
|
||
.font(.headline)
|
||
.fontWeight(.semibold)
|
||
.foregroundColor(.primary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.top, 16)
|
||
.padding(.bottom, 4)
|
||
.padding(.horizontal, 8)
|
||
}
|
||
|
||
private func renderLine(_ line: String) -> some View {
|
||
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
|
||
guard !trimmedLine.isEmpty else { return AnyView(EmptyView()) }
|
||
|
||
return AnyView(
|
||
HStack(alignment: .center, spacing: 4) {
|
||
ForEach(processLine(trimmedLine), id: \.id) { segment in
|
||
switch segment.type {
|
||
case .text:
|
||
renderTextSegment(segment)
|
||
case .hymn(let number):
|
||
renderHymnSegment(segment, number: number)
|
||
case .responsiveReading(let number):
|
||
renderResponsiveReadingSegment(segment, number: number)
|
||
case .bibleVerse:
|
||
if let reference = segment.reference {
|
||
renderBibleVerseSegment(segment, reference: reference)
|
||
}
|
||
case .sectionHeader:
|
||
renderSectionHeaderSegment(segment)
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
)
|
||
}
|
||
|
||
private func renderSection(_ title: String, content: String) -> some View {
|
||
VStack(alignment: .center, spacing: 16) {
|
||
Text(title)
|
||
.font(.title)
|
||
.fontWeight(.bold)
|
||
.foregroundColor(.primary)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.padding(.bottom, 8)
|
||
|
||
VStack(alignment: .center, spacing: 12) {
|
||
ForEach(Array(zip(content.components(separatedBy: .newlines).indices,
|
||
content.components(separatedBy: .newlines))), id: \.0) { _, line in
|
||
renderLine(line)
|
||
}
|
||
}
|
||
}
|
||
.padding(.vertical, 12)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .center, spacing: 24) {
|
||
ForEach(sectionOrder, id: \.0) { (title, keyPath) in
|
||
let content = bulletin[keyPath: keyPath]
|
||
if !content.isEmpty {
|
||
renderSection(title, content: content)
|
||
|
||
if title != sectionOrder.last?.0 {
|
||
Divider()
|
||
.padding(.vertical, 8)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
BulletinView()
|
||
}
|