diff --git a/Services/AppAvailabilityService.swift b/Services/AppAvailabilityService.swift index 20da01c..7299fe3 100644 --- a/Services/AppAvailabilityService.swift +++ b/Services/AppAvailabilityService.swift @@ -112,6 +112,30 @@ class AppAvailabilityService { } } + // Open a specific responsive reading by number + func openResponsiveReadingByNumber(_ readingNumber: Int) { + // Format: adventisthymnarium://reading?number=123 + let hymnalScheme = "\(Schemes.hymnal)reading?number=\(readingNumber)" + print("📖 Attempting to open responsive reading #\(readingNumber): \(hymnalScheme)") + + if let readingUrl = URL(string: hymnalScheme) { + if UIApplication.shared.canOpenURL(readingUrl) { + UIApplication.shared.open(readingUrl) { success in + if !success { + print("❌ Failed to open responsive reading #\(readingNumber)") + self.openApp(urlScheme: Schemes.hymnal, fallbackURL: AppStoreURLs.hymnal) + } + } + } else { + print("❌ Cannot open responsive reading #\(readingNumber)") + openApp(urlScheme: Schemes.hymnal, fallbackURL: AppStoreURLs.hymnal) + } + } else { + print("⚠️ Failed to create URL for responsive reading #\(readingNumber)") + openApp(urlScheme: Schemes.hymnal, fallbackURL: AppStoreURLs.hymnal) + } + } + private func handleFallback(urlScheme: String, fallbackURL: String) { // Try the App Store URL first if let fallback = URL(string: fallbackURL) { diff --git a/Views/BulletinView.swift b/Views/BulletinView.swift index 69588e4..e389ada 100644 --- a/Views/BulletinView.swift +++ b/Views/BulletinView.swift @@ -302,6 +302,7 @@ struct BulletinContentView: View { enum ContentType { case text case hymn(number: Int) + case responsiveReading(number: Int) case bibleVerse case sectionHeader } @@ -353,7 +354,36 @@ struct BulletinContentView: View { var segments: [ContentSegment] = [] let nsLine = cleanedLine as NSString - // Check for section headers with specific patterns + // 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):?"#, @@ -385,94 +415,119 @@ struct BulletinContentView: View { for pattern in headerPatterns { let headerRegex = try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) - let headerMatches = headerRegex.matches(in: cleanedLine, range: NSRange(location: 0, length: nsLine.length)) + let headerMatches = headerRegex.matches(in: line, range: NSRange(location: 0, length: nsLine.length)) if !headerMatches.isEmpty { - // Add the header let headerText = nsLine.substring(with: headerMatches[0].range(at: 1)) .trimmingCharacters(in: .whitespaces) - segments.append((id: UUID(), text: headerText, type: .sectionHeader, reference: nil)) - - // Add any remaining text after the header - if headerMatches[0].range.location + headerMatches[0].range.length < nsLine.length { - let remainingText = nsLine.substring(from: headerMatches[0].range.location + headerMatches[0].range.length) - .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ":"))) - if !remainingText.isEmpty { - segments.append((id: UUID(), text: remainingText, type: .text, reference: nil)) - } - } - return segments + return (id: UUID(), text: headerText, type: .sectionHeader, reference: nil) } } - // Match hymn numbers with surrounding text + 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: cleanedLine, range: NSRange(location: 0, length: nsLine.length)) + let hymnMatches = hymnRegex.matches(in: line, range: NSRange(location: 0, length: nsLine.length)) - // Match Bible verses (simplified pattern) + 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: cleanedLine, range: NSRange(location: 0, length: nsLine.length)) + let verseMatches = verseRegex.matches(in: line, 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 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)) } } - } 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 - } + let verseText = nsLine.substring(with: match.range) + .trimmingCharacters(in: .whitespaces) - // 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)) - } + segments.append((id: UUID(), text: verseText, type: .bibleVerse, reference: verseText)) + + 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)) } - } else { - // Regular text - segments.append((id: UUID(), text: cleanedLine, type: .text, reference: nil)) } return segments @@ -530,88 +585,130 @@ struct BulletinContentView: View { 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 { - 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) { 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) - .fontWeight(.semibold) - .foregroundColor(.primary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 16) - .padding(.bottom, 4) - .padding(.horizontal, 8) - } - } - } - .frame(maxWidth: .infinity, alignment: .center) - } - } - } - } - .padding(.vertical, 12) + renderSection(title, content: content) if title != sectionOrder.last?.0 { Divider()