import SwiftUI import PDFKit struct BulletinDetailView: View { let bulletin: ChurchBulletin @State private var showingPDFViewer = false @Environment(\.horizontalSizeClass) private var horizontalSizeClass var body: some View { ScrollView { VStack(spacing: 20) { // Header Card headerCard // Content Sections if !bulletin.sabbathSchool.isEmpty || !bulletin.divineWorship.isEmpty { contentSectionsCard } // Scripture & Sunset Card scriptureAndSunsetCard // Actions Card actionsCard } .padding() } .navigationTitle("") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbarBackground(.visible, for: .navigationBar) .toolbarBackground(.regularMaterial, for: .navigationBar) .sheet(isPresented: $showingPDFViewer) { if let pdfUrl = bulletin.pdfPath, let url = URL(string: pdfUrl) { PDFViewerSheet(url: url, title: bulletin.title) } } } // MARK: - Header Card private var headerCard: some View { VStack(alignment: .leading, spacing: 16) { // Top section with icon and metadata HStack { Image(systemName: "doc.text.fill") .font(.system(size: 28)) .foregroundStyle(Color(hex: "fb8b23")) VStack(alignment: .leading, spacing: 4) { Text("Church Bulletin") .font(.caption) .fontWeight(.medium) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.green.opacity(0.1), in: Capsule()) .foregroundStyle(.green) Text(bulletin.formattedDate) .font(.caption) .foregroundStyle(.secondary) } Spacer() if bulletin.pdfPath != nil { Image(systemName: "arrow.down.circle.fill") .font(.title2) .foregroundStyle(.green) } } // Title Text(bulletin.title) .font(.title2) .fontWeight(.bold) .multilineTextAlignment(.leading) // Main PDF Action if bulletin.pdfPath != nil { Button(action: { showingPDFViewer = true }) { HStack { Image(systemName: "doc.text.viewfinder") .font(.title3) Text("View PDF Bulletin") .font(.headline) .fontWeight(.semibold) Spacer() Image(systemName: "arrow.right.circle.fill") .font(.title3) } .foregroundStyle(.white) .padding() .background(Color(hex: "fb8b23"), in: RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) .shadow(color: Color(hex: "fb8b23").opacity(0.3), radius: 4, x: 0, y: 2) } } .padding(20) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) } // MARK: - Content Sections Card private var contentSectionsCard: some View { VStack(alignment: .leading, spacing: 20) { // Header HStack { Image(systemName: "list.bullet.clipboard") .font(.title3) .foregroundStyle(Color(hex: "fb8b23")) Text("Service Order") .font(.headline) .fontWeight(.semibold) Spacer() } VStack(spacing: 16) { if !bulletin.sabbathSchool.isEmpty { ServiceSectionView( title: "Sabbath School", content: bulletin.sabbathSchool, icon: "book.fill", color: .blue ) } if !bulletin.divineWorship.isEmpty { ServiceSectionView( title: "Divine Worship", content: bulletin.divineWorship, icon: "hands.sparkles.fill", color: .purple ) } } } .padding(20) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) } // MARK: - Scripture & Sunset Card private var scriptureAndSunsetCard: some View { VStack(spacing: 16) { if !bulletin.scriptureReading.isEmpty { ScriptureReadingView(scripture: bulletin.scriptureReading) } SunsetTimeView(sunset: bulletin.sunset) } .padding(20) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) } // MARK: - Actions Card private var actionsCard: some View { VStack(spacing: 16) { HStack { Image(systemName: "square.and.arrow.up") .font(.title3) .foregroundStyle(.blue) Text("Quick Actions") .font(.headline) .fontWeight(.semibold) Spacer() } HStack(spacing: 12) { QuickActionButton( title: "Share", icon: "square.and.arrow.up", color: .blue ) { shareBulletin() } if bulletin.pdfPath != nil { QuickActionButton( title: "Download", icon: "arrow.down.circle", color: .green ) { downloadBulletin() } } QuickActionButton( title: "Contact", icon: "phone.fill", color: .orange ) { if let url = URL(string: "tel://8608750450") { UIApplication.shared.open(url) } } } } .padding(20) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4) } // MARK: - Helper Functions private func shareBulletin() { var items: [Any] = ["Check out this week's church bulletin: \(bulletin.title)"] if let pdfPath = bulletin.pdfPath, let url = URL(string: pdfPath) { items.append(url) } #if os(iOS) let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil) if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first { window.rootViewController?.present(activityVC, animated: true) } #endif } private func downloadBulletin() { guard let pdfPath = bulletin.pdfPath, let url = URL(string: pdfPath) else { return } // For remote PDFs, open in Safari which handles downloads properly if url.scheme == "http" || url.scheme == "https" { UIApplication.shared.open(url) } } } // MARK: - PDF Viewer Sheet struct PDFViewerSheet: View { let url: URL let title: String @Environment(\.dismiss) private var dismiss @State private var isLoading = true @State private var error: Error? var body: some View { NavigationStack { ZStack { if isLoading { VStack(spacing: 16) { ProgressView() .scaleEffect(1.2) Text("Loading PDF...") .font(.subheadline) .foregroundStyle(.secondary) } } else if let error = error { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 48)) .foregroundStyle(.red) Text("Error Loading PDF") .font(.headline) .fontWeight(.semibold) Text(error.localizedDescription) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button("Try Again") { loadPDF() } .buttonStyle(.borderedProminent) } .padding() } else { PDFKitView(url: url, isLoading: $isLoading, error: $error) } } .navigationTitle(title) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Done") { dismiss() } } ToolbarItem(placement: .navigationBarTrailing) { Menu { Button { // Share PDF } label: { Label("Share", systemImage: "square.and.arrow.up") } Button { // Download PDF } label: { Label("Download", systemImage: "arrow.down.circle") } Button { // Print PDF } label: { Label("Print", systemImage: "printer") } } label: { Image(systemName: "ellipsis.circle") } } } } .onAppear { loadPDF() } } private func loadPDF() { isLoading = true error = nil // Simulate loading or implement actual PDF loading logic DispatchQueue.main.asyncAfter(deadline: .now() + 1) { isLoading = false } } } // MARK: - PDFKit View struct PDFKitView: UIViewRepresentable { let url: URL @Binding var isLoading: Bool @Binding var error: Error? func makeUIView(context: Context) -> PDFView { let pdfView = PDFView() pdfView.autoScales = true pdfView.displayMode = .singlePageContinuous pdfView.displayDirection = .vertical #if os(iOS) pdfView.backgroundColor = .systemBackground #else pdfView.backgroundColor = .black #endif // Load PDF loadPDF(into: pdfView) return pdfView } func updateUIView(_ uiView: PDFView, context: Context) { // Update if needed } private func loadPDF(into pdfView: PDFView) { Task { do { let (data, _) = try await URLSession.shared.data(from: url) await MainActor.run { if let pdfDocument = PDFDocument(data: data) { pdfView.document = pdfDocument self.isLoading = false } else { self.error = URLError(.cannotDecodeContentData) self.isLoading = false } } } catch { await MainActor.run { self.error = error self.isLoading = false } } } } } // MARK: - Supporting View Components struct ServiceSectionView: View { let title: String let content: String let icon: String let color: Color var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Image(systemName: icon) .font(.subheadline) .foregroundStyle(color) Text(title) .font(.subheadline) .fontWeight(.semibold) .foregroundStyle(color) Spacer() } HymnTextView(text: content) .padding(.leading, 4) } .padding(16) .background(color.opacity(0.05), in: RoundedRectangle(cornerRadius: 12)) } } struct ScriptureReadingView: View { let scripture: String private func parseScripture(_ text: String) -> [ScriptureVerse] { // Use the Rust implementation for consistent scripture parsing let jsonString = formatScriptureTextJson(scriptureText: text) guard let data = jsonString.data(using: .utf8), let scriptureSection = try? JSONDecoder().decode([ScriptureSection].self, from: data) else { // Fallback to original numbered verse parsing if Rust parsing fails return parseNumberedVerses(text) } var verses: [ScriptureVerse] = [] for section in scriptureSection { // If we have both verse and reference, add them separately if !section.reference.isEmpty { verses.append(ScriptureVerse(number: nil, text: section.verse)) verses.append(ScriptureVerse(number: nil, text: section.reference)) } else { // Single verse or text without reference verses.append(ScriptureVerse(number: nil, text: section.verse)) } } // If still no verses found, fallback if verses.isEmpty { verses.append(ScriptureVerse(number: nil, text: text.trimmingCharacters(in: .whitespacesAndNewlines))) } return verses } private func parseNumberedVerses(_ text: String) -> [ScriptureVerse] { // Fallback: Check for numbered verses like "1. text" or "1 text" let lines = text.components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } var verses: [ScriptureVerse] = [] for line in lines { // Check if line starts with a number if let firstChar = line.first, firstChar.isNumber { let components = line.components(separatedBy: CharacterSet(charactersIn: ". ")) if components.count > 1, let number = Int(components[0]), components.count > 1 { let text = components.dropFirst().joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) if !text.isEmpty { verses.append(ScriptureVerse(number: number, text: text)) continue } } } // Add as regular text verses.append(ScriptureVerse(number: nil, text: line)) } // If no verses found, treat as single verse if verses.isEmpty { verses.append(ScriptureVerse(number: nil, text: text.trimmingCharacters(in: .whitespacesAndNewlines))) } return verses } var body: some View { let verses = parseScripture(scripture) VStack(alignment: .leading, spacing: 16) { // Header with enhanced styling HStack(spacing: 8) { ZStack { Circle() .fill(.indigo.opacity(0.15)) .frame(width: 28, height: 28) Image(systemName: "book.closed.fill") .font(.caption) .foregroundStyle(.indigo) } Text("Scripture Reading") .font(.subheadline) .fontWeight(.semibold) .foregroundStyle(.indigo) Spacer() // Verse count indicator for multiple verses if verses.count > 1 { Text("\(verses.count) verses") .font(.caption2) .padding(.horizontal, 8) .padding(.vertical, 3) .background(.indigo.opacity(0.1), in: Capsule()) .foregroundStyle(.indigo) } } // Verses with enhanced styling VStack(spacing: verses.count > 1 ? 12 : 8) { ForEach(Array(verses.enumerated()), id: \.offset) { index, verse in VStack(alignment: .leading, spacing: 6) { if let number = verse.number { HStack(spacing: 8) { // Verse number badge Text("\(number)") .font(.caption) .fontWeight(.bold) .foregroundStyle(.white) .frame(width: 20, height: 20) .background(.indigo, in: Circle()) // Verse text with elegant styling Text(verse.text) .font(.body) .fontWeight(.medium) .foregroundStyle(.primary) .multilineTextAlignment(.leading) } } else { // Single verse or paragraph without numbering HStack(alignment: .top, spacing: 8) { // Decorative quote mark or bullet if verses.count == 1 { Image(systemName: "quote.opening") .font(.caption) .foregroundStyle(.indigo.opacity(0.6)) .padding(.top, 2) } else { Circle() .fill(.indigo.opacity(0.4)) .frame(width: 4, height: 4) .padding(.top, 8) } Text(verse.text) .font(.body) .fontWeight(.medium) .foregroundStyle(.primary) .multilineTextAlignment(.leading) } } } .padding(.leading, 4) // Separator between verses (except last one) if index < verses.count - 1 { HStack { Spacer() Rectangle() .fill(.indigo.opacity(0.2)) .frame(width: 30, height: 1) Spacer() } } } } } .padding(20) .background(.indigo.opacity(0.03), in: RoundedRectangle(cornerRadius: 16)) .overlay( RoundedRectangle(cornerRadius: 16) .strokeBorder(.indigo.opacity(0.15), lineWidth: 1.5) ) .shadow(color: .indigo.opacity(0.1), radius: 4, x: 0, y: 2) } } // MARK: - Scripture Verse Model struct ScriptureVerse { let number: Int? let text: String } struct SunsetTimeView: View { let sunset: String var body: some View { HStack(spacing: 12) { Image(systemName: "sunset.fill") .font(.title3) .foregroundStyle(.orange) VStack(alignment: .leading, spacing: 2) { Text("Sabbath Ends") .font(.caption) .foregroundStyle(.secondary) Text(sunset) .font(.headline) .fontWeight(.semibold) } Spacer() Image(systemName: "clock.fill") .font(.title3) .foregroundStyle(.orange.opacity(0.6)) } .padding(16) .background(.orange.opacity(0.05), in: RoundedRectangle(cornerRadius: 12)) } } struct QuickActionButton: View { let title: String let icon: String let color: Color let action: () -> Void var body: some View { Button(action: action) { VStack(spacing: 8) { Image(systemName: icon) .font(.title2) .foregroundStyle(color) Text(title) .font(.caption) .fontWeight(.medium) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 16) .background(color.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) } } // MARK: - Hymn Text View with Clickable Hymn Numbers struct HymnTextView: View { let text: String private func parseTextWithHymns(_ text: String) -> [(text: String, isHymn: Bool, hymnNumber: Int?)] { var result: [(text: String, isHymn: Bool, hymnNumber: Int?)] = [] let hymnPattern = #"(?:Hymn(?:al)?\s+(?:#\s*)?|#\s*)(\d+)(?:\s+["""]([^"""]*)[""])?.*"# guard let regex = try? NSRegularExpression(pattern: hymnPattern, options: [.caseInsensitive]) else { return [(text: text, isHymn: false, hymnNumber: nil)] } let nsText = text as NSString let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)) if matches.isEmpty { return [(text: text, isHymn: false, hymnNumber: nil)] } var lastIndex = 0 for match in matches { // Add text before the hymn match if match.range.location > lastIndex { let textBefore = nsText.substring(with: NSRange(location: lastIndex, length: match.range.location - lastIndex)) if !textBefore.isEmpty { result.append((text: textBefore, isHymn: false, hymnNumber: nil)) } } // Add hymn match let hymnText = nsText.substring(with: match.range) let hymnNumber = Int(nsText.substring(with: match.range(at: 1))) ?? 0 result.append((text: hymnText, isHymn: true, hymnNumber: hymnNumber)) lastIndex = match.range.location + match.range.length } // Add remaining text after the last match if lastIndex < nsText.length { let remainingText = nsText.substring(from: lastIndex) if !remainingText.isEmpty { result.append((text: remainingText, isHymn: false, hymnNumber: nil)) } } return result } var body: some View { let parsedText = parseTextWithHymns(text) if parsedText.count == 1 && !parsedText[0].isHymn { // No hymns found, just show regular text Text(text) .font(.body) } else { // Mix of text and hymn numbers VStack(alignment: .leading, spacing: 8) { ForEach(Array(parsedText.enumerated()), id: \.offset) { _, segment in if segment.isHymn, let hymnNumber = segment.hymnNumber { Button(action: { openHymnInAdventistHymnarium(hymnNumber) }) { HStack(spacing: 6) { Image(systemName: "music.note") .foregroundStyle(.blue) .font(.caption) Text(segment.text) .font(.body) .foregroundStyle(.blue) } .padding(.horizontal, 8) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: 6) .fill(.blue.opacity(0.1)) ) } .buttonStyle(.plain) } else { Text(segment.text) .font(.body) } } } } } private func openHymnInAdventistHymnarium(_ number: Int) { // Try to open Adventist Hymnarium app first let hymnAppURL = URL(string: "adventisthymnarium://hymn?number=\(number)") let appStoreURL = URL(string: "https://apps.apple.com/us/app/adventist-hymnarium/id6738877733") if let hymnAppURL = hymnAppURL, UIApplication.shared.canOpenURL(hymnAppURL) { // Open in Adventist Hymnarium app UIApplication.shared.open(hymnAppURL) } else if let appStoreURL = appStoreURL { // Offer to download the app let alert = UIAlertController( title: "Open in Adventist Hymnarium", message: "Download the Adventist Hymnarium app to view Hymn #\(number)?", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "Download App", style: .default) { _ in UIApplication.shared.open(appStoreURL) }) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first, let rootViewController = window.rootViewController { rootViewController.present(alert, animated: true) } } } } #Preview { NavigationStack { BulletinDetailView(bulletin: ChurchBulletin( id: "1", title: "January 11, 2025", date: "Saturday, January 11, 2025", sabbathSchool: "The Beatitudes - Part 3", divineWorship: "Blessed Are Those Who Hunger", scriptureReading: "Matthew 5:1-12", sunset: "5:47 PM", pdfPath: "https://example.com/bulletin.pdf", coverImage: nil, isActive: true )) } }