
- 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
795 lines
28 KiB
Swift
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
|
|
))
|
|
}
|
|
}
|