RTSDA-iOS/Views/Detail/BulletinDetailView.swift
RTSDA 00679f927c docs: Update README for v2.0 release and fix git remote URL
- Comprehensive README update documenting v2.0 architectural changes
- Updated git remote to ssh://rockvilleav@git.rockvilletollandsda.church:10443/RTSDA/RTSDA-iOS.git
- Documented unified ChurchService and 60% code reduction
- Added new features: Home Feed, responsive reading, enhanced UI
- Corrected license information (GPL v3 with church content copyright)
- Updated build instructions and technical stack details
2025-08-16 18:41:51 -04:00

795 lines
28 KiB
Swift

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