diff --git a/Core/Localization/Resources/ar.lproj/Localizable.strings b/Core/Localization/Resources/ar.lproj/Localizable.strings index bba2e8157..2075df5a0 100644 --- a/Core/Localization/Resources/ar.lproj/Localizable.strings +++ b/Core/Localization/Resources/ar.lproj/Localizable.strings @@ -123,6 +123,17 @@ "bookmarks.sync.body" = "سجّل الدخول إلى Quran.com للاحتفاظ بعلاماتك المرجعية متاحة عبر أجهزتك."; "bookmarks.sync.action" = "تسجيل الدخول"; +"bookmarks.collections" = "المجموعات"; +"bookmarks.collections.add" = "إضافة مجموعة"; +"bookmarks.collections.edit" = "تحرير المجموعات"; +"bookmarks.collections.new.placeholder" = "اسم المجموعة"; +"bookmarks.collections.no-data.title" = "لا توجد مجموعات حتى الآن"; +"bookmarks.collections.no-data.text" = "أنشئ مجموعة لتنظيم إشاراتك المرجعية للآيات."; +"bookmarks.collections.ayah-count" = "%d إشارات مرجعية للآيات"; +"bookmarks.collections.ayahs.no-data.title" = "لا توجد إشارات مرجعية في هذه المجموعة"; +"bookmarks.collections.ayahs.no-data.text" = "أضف إشارات مرجعية للآيات لرؤيتها هنا."; +"bookmarks.highlights" = "التمييزات"; + // MARK: - Notes "notes.no-data.title" = "إضافة آيات مرجعية..."; @@ -156,6 +167,14 @@ "ayah.menu.delete-note" = "حذف الملاحظة"; "ayah.menu.delete-highlight" = "حذف التحديد"; +// MARK: - Highlights + +"highlight.color.red" = "أحمر"; +"highlight.color.green" = "أخضر"; +"highlight.color.blue" = "أزرق"; +"highlight.color.yellow" = "أصفر"; +"highlight.color.purple" = "بنفسجي"; + // MARK: - Reading Selector // Selector UI @@ -246,6 +265,19 @@ "bookmarks.collections.new.placeholder" = "اسم المجموعة"; "bookmarks.collections.no-data.title" = "لا توجد مجموعات حتى الآن"; "bookmarks.collections.no-data.text" = "أنشئ مجموعة لتنظيم إشاراتك المرجعية للآيات."; - "notes.minimum-length.alert.title" = "الملاحظة قصيرة جدًا"; "notes.minimum-length.alert.body" = "يجب أن تكون الملاحظة %d أحرف على الأقل."; + +"highlight.color.red" = "أحمر"; +"highlight.color.green" = "أخضر"; +"highlight.color.blue" = "أزرق"; +"highlight.color.yellow" = "أصفر"; +"highlight.color.purple" = "بنفسجي"; + +// MARK: - Reading Bookmark +"reading-bookmark.education.title.prefix" = "هذه "; +"reading-bookmark.education.title.link" = "علامة قراءة متنقلة"; +"reading-bookmark.education.body" = "حفظ علامة قراءة سيستبدل علامة القراءة القديمة. لحفظ عدة آيات، استخدم ميزة التمييز."; +"reading-bookmark.education.undo" = "تراجع"; +"reading-bookmark.title" = "علامة القراءة"; +"reading-bookmark.my-title" = "علامة القراءة الخاصة بي"; diff --git a/Core/Localization/Resources/en.lproj/Localizable.strings b/Core/Localization/Resources/en.lproj/Localizable.strings index 9035e2cf5..14dcb43ab 100644 --- a/Core/Localization/Resources/en.lproj/Localizable.strings +++ b/Core/Localization/Resources/en.lproj/Localizable.strings @@ -127,6 +127,17 @@ "bookmarks.sync.body" = "Sign in to Quran.com to keep your bookmarks available across devices."; "bookmarks.sync.action" = "Sign In"; +"bookmarks.collections" = "Collections"; +"bookmarks.collections.add" = "Add Collection"; +"bookmarks.collections.edit" = "Edit Collections"; +"bookmarks.collections.new.placeholder" = "Collection name"; +"bookmarks.collections.no-data.title" = "No collections yet"; +"bookmarks.collections.no-data.text" = "Create a collection to organize ayah bookmarks."; +"bookmarks.collections.ayah-count" = "%d ayah bookmarks"; +"bookmarks.collections.ayahs.no-data.title" = "No bookmarks in this collection"; +"bookmarks.collections.ayahs.no-data.text" = "Add ayah bookmarks to see them here."; +"bookmarks.highlights" = "Highlights"; + // MARK: - Notes "notes.no-data.title" = "Adding Ayah Highlights..."; @@ -160,6 +171,14 @@ "ayah.menu.delete-note" = "Delete Note"; "ayah.menu.delete-highlight" = "Delete Highlight"; +// MARK: - Highlights + +"highlight.color.red" = "Red"; +"highlight.color.green" = "Green"; +"highlight.color.blue" = "Blue"; +"highlight.color.yellow" = "Yellow"; +"highlight.color.purple" = "Purple"; + // MARK: - Reading Selector // Selector UI @@ -252,8 +271,21 @@ "bookmarks.collections.new.placeholder" = "Collection name"; "bookmarks.collections.no-data.title" = "No collections yet"; "bookmarks.collections.no-data.text" = "Create a collection to organize your ayah bookmarks."; - "bookmarks.old-page-bookmarks" = "Old Page Bookmarks"; "notes.minimum-length.alert.title" = "Note is too short"; "notes.minimum-length.alert.body" = "Notes must be at least %d characters."; + +"highlight.color.red" = "Red"; +"highlight.color.green" = "Green"; +"highlight.color.blue" = "Blue"; +"highlight.color.yellow" = "Yellow"; +"highlight.color.purple" = "Purple"; + +// MARK: - Reading Bookmark +"reading-bookmark.education.title.prefix" = "This is a movable "; +"reading-bookmark.education.title.link" = "reading bookmark"; +"reading-bookmark.education.body" = "Saving a reading bookmark will now replace your old reading bookmark. To save multiple verses, please use the highlight feature."; +"reading-bookmark.education.undo" = "Undo"; +"reading-bookmark.title" = "Reading Bookmark"; +"reading-bookmark.my-title" = "My Reading Bookmark"; diff --git a/Domain/AnnotationsService/Sources/ReadingBookmarkService.swift b/Domain/AnnotationsService/Sources/ReadingBookmarkService.swift new file mode 100644 index 000000000..f37a81264 --- /dev/null +++ b/Domain/AnnotationsService/Sources/ReadingBookmarkService.swift @@ -0,0 +1,153 @@ +// +// ReadingBookmarkService.swift +// +// Created by Ahmed Nabil on 2026-05-09. +// + +import Foundation +#if QURAN_SYNC + import MobileSync + import ReadingService +#endif +import QuranKit + +public enum QuranReadingBookmark: Equatable { + case ayah(AyahNumber, Date) + case page(Page, Date) + + public var ayah: AyahNumber { + switch self { + case .ayah(let ayah, _): ayah + case .page(let page, _): page.firstVerse + } + } + + public var page: Page { + switch self { + case .ayah(let ayah, _): ayah.page + case .page(let page, _): page + } + } + + public var lastUpdated: Date { + switch self { + case .ayah(_, let lastUpdated), .page(_, let lastUpdated): lastUpdated + } + } + + public func isAyahBookmark(for ayah: AyahNumber) -> Bool { + guard case .ayah(let bookmarkedAyah, _) = self else { + return false + } + return bookmarkedAyah == ayah + } + + public func isReadingBookmark(for ayah: AyahNumber) -> Bool { + switch self { + case .ayah(let bookmarkedAyah, _): + return bookmarkedAyah == ayah + case .page(let page, _): + return page == ayah.page + } + } + + public func isPageBookmark(for pages: [Page]) -> Bool { + guard case .page(let page, _) = self else { + return false + } + return pages.contains(page) + } +} + +#if QURAN_SYNC + public struct ReadingBookmarkSequence: AsyncSequence { + public typealias Element = QuranReadingBookmark? + + public struct AsyncIterator: AsyncIteratorProtocol { + init(_ sequence: S) where S.Element == Element { + var iterator = sequence.makeAsyncIterator() + nextValue = { + try await iterator.next() + } + } + + public mutating func next() async throws -> Element? { + try await nextValue() + } + + private let nextValue: () async throws -> Element? + } + + init(_ sequence: S) where S.Element == Element { + makeIterator = { + AsyncIterator(sequence) + } + } + + public func makeAsyncIterator() -> AsyncIterator { + makeIterator() + } + + private let makeIterator: () -> AsyncIterator + } + + public struct ReadingBookmarkService { + // MARK: Lifecycle + + public init(syncService: SyncService, readingPreferences: ReadingPreferences = .shared) { + self.syncService = syncService + self.readingPreferences = readingPreferences + } + + // MARK: Public + + public func readingBookmarkSequence() -> ReadingBookmarkSequence { + let sequence = syncService.readingBookmarkSequence() + .map { bookmark in + Self.bookmark(from: bookmark, quran: readingPreferences.reading.quran) + } + return ReadingBookmarkSequence(sequence) + } + + @discardableResult + public func addReadingBookmark(page: Page) async throws -> QuranReadingBookmark { + let bookmark = try await syncService.addPageReadingBookmark(page: Int32(page.pageNumber)) + return .page(page, bookmark.lastUpdated) + } + + @discardableResult + public func addReadingBookmark(ayah: AyahNumber) async throws -> QuranReadingBookmark { + let bookmark = try await syncService.addAyahReadingBookmark( + sura: Int32(ayah.sura.suraNumber), + ayah: Int32(ayah.ayah) + ) + return .ayah(ayah, bookmark.lastUpdated) + } + + public func removeReadingBookmark() async throws { + _ = try await syncService.removeReadingBookmark() + } + + public static func bookmark(from bookmark: (any ReadingBookmark)?, quran: Quran) -> QuranReadingBookmark? { + switch bookmark { + case let bookmark as AyahReadingBookmark: + return AyahNumber(quran: quran, sura: Int(bookmark.sura), ayah: Int(bookmark.ayah)).map { + QuranReadingBookmark.ayah($0, bookmark.lastUpdated) + } + case let bookmark as PageReadingBookmark: + return Page(quran: quran, pageNumber: Int(bookmark.page)).map { + QuranReadingBookmark.page($0, bookmark.lastUpdated) + } + default: + return nil + } + } + + // MARK: Private + + private let syncService: SyncService + private let readingPreferences: ReadingPreferences + } +#else + public struct ReadingBookmarkService {} +#endif diff --git a/Domain/AnnotationsService/Tests/ReadingBookmarkServiceTests.swift b/Domain/AnnotationsService/Tests/ReadingBookmarkServiceTests.swift new file mode 100644 index 000000000..88fa0c1be --- /dev/null +++ b/Domain/AnnotationsService/Tests/ReadingBookmarkServiceTests.swift @@ -0,0 +1,60 @@ +#if QURAN_SYNC + import MobileSync + import QuranKit + import XCTest + @testable import AnnotationsService + + final class ReadingBookmarkServiceTests: XCTestCase { + func test_bookmark_mapsAyahReadingBookmark() { + let bookmark = AyahReadingBookmark( + sura: 1, + ayah: 1, + lastUpdated: .distantPast, + localId: "ayah-bookmark" + ) + + XCTAssertEqual( + ReadingBookmarkService.bookmark(from: bookmark, quran: .hafsMadani1405), + .ayah(AyahNumber(quran: .hafsMadani1405, sura: 1, ayah: 1)!, .distantPast) + ) + } + + func test_bookmark_mapsPageReadingBookmark() { + let bookmark = PageReadingBookmark( + page: 1, + lastUpdated: .distantPast, + localId: "page-bookmark" + ) + + XCTAssertEqual( + ReadingBookmarkService.bookmark(from: bookmark, quran: .hafsMadani1405), + .page(Page(quran: .hafsMadani1405, pageNumber: 1)!, .distantPast) + ) + } + + func test_isReadingBookmark_matchesPageBookmarkAyah() { + let ayah = AyahNumber(quran: .hafsMadani1405, sura: 1, ayah: 1)! + let bookmark = QuranReadingBookmark.page(ayah.page, .distantPast) + + XCTAssertTrue(bookmark.isReadingBookmark(for: ayah)) + } + + func test_isPageBookmark_doesNotMatchAyahBookmarkPage() { + let ayah = AyahNumber(quran: .hafsMadani1405, sura: 1, ayah: 1)! + let bookmark = QuranReadingBookmark.ayah(ayah, .distantPast) + + XCTAssertFalse(bookmark.isPageBookmark(for: [ayah.page])) + } + + func test_bookmark_skipsInvalidAyah() { + let bookmark = AyahReadingBookmark( + sura: 999, + ayah: 1, + lastUpdated: .distantPast, + localId: "invalid-ayah-bookmark" + ) + + XCTAssertNil(ReadingBookmarkService.bookmark(from: bookmark, quran: .hafsMadani1405)) + } + } +#endif diff --git a/Features/AyahMenuFeature/AyahMenuBuilder.swift b/Features/AyahMenuFeature/AyahMenuBuilder.swift index 3da4c1f3a..b4aeb7c22 100644 --- a/Features/AyahMenuFeature/AyahMenuBuilder.swift +++ b/Features/AyahMenuFeature/AyahMenuBuilder.swift @@ -7,6 +7,9 @@ // import AppDependencies +#if QURAN_SYNC + import BookmarksFeature +#endif import QuranAnnotations import QuranKit import QuranTextKit @@ -15,12 +18,20 @@ import UIKit public struct AyahMenuInput { // MARK: Lifecycle - public init(sourceView: UIView, pointInView: CGPoint, verses: [AyahNumber], notes: [Note], noteCount: Int = 0) { + public init( + sourceView: UIView, + pointInView: CGPoint, + verses: [AyahNumber], + notes: [QuranAnnotations.Note], + noteCount: Int = 0, + highlightColor: HighlightColor? = nil + ) { self.sourceView = sourceView self.pointInView = pointInView self.verses = verses self.notes = notes self.noteCount = noteCount + self.highlightColor = highlightColor } // MARK: Internal @@ -28,8 +39,9 @@ public struct AyahMenuInput { let sourceView: UIView let pointInView: CGPoint let verses: [AyahNumber] - let notes: [Note] + let notes: [QuranAnnotations.Note] let noteCount: Int + let highlightColor: HighlightColor? } @MainActor @@ -48,16 +60,31 @@ public struct AyahMenuBuilder { quranFileURL: container.quranUthmaniV2Database ) let noteService = container.noteService() - let viewModel = AyahMenuViewModel(deps: AyahMenuViewModel.Deps( - sourceView: input.sourceView, - pointInView: input.pointInView, - verses: input.verses, - notes: input.notes, - noteService: noteService, - textRetriever: textRetriever, - usesSyncedNotes: usesSyncedNotes, - noteCount: input.noteCount - )) + #if QURAN_SYNC + let deps = AyahMenuViewModel.Deps( + sourceView: input.sourceView, + pointInView: input.pointInView, + verses: input.verses, + notes: input.notes, + noteService: noteService, + textRetriever: textRetriever, + highlightColor: input.highlightColor, + usesSyncedNotes: usesSyncedNotes, + noteCount: input.noteCount, + ayahBookmarkCollectionService: container.syncService.map { AyahBookmarkCollectionService(syncService: $0) } + ) + #else + let deps = AyahMenuViewModel.Deps( + sourceView: input.sourceView, + pointInView: input.pointInView, + verses: input.verses, + notes: input.notes, + noteService: noteService, + textRetriever: textRetriever, + highlightColor: input.highlightColor + ) + #endif + let viewModel = AyahMenuViewModel(deps: deps) viewModel.listener = listener return AyahMenuViewController(viewModel: viewModel) } diff --git a/Features/AyahMenuFeature/AyahMenuViewModel.swift b/Features/AyahMenuFeature/AyahMenuViewModel.swift index fcb0dd209..de570db46 100644 --- a/Features/AyahMenuFeature/AyahMenuViewModel.swift +++ b/Features/AyahMenuFeature/AyahMenuViewModel.swift @@ -8,6 +8,9 @@ import AnnotationsService import Crashing +#if QURAN_SYNC + import BookmarksFeature +#endif import Localization import NoorUI import QuranAnnotations @@ -25,14 +28,14 @@ public protocol AyahMenuListener: AnyObject { func playAudio(_ from: AyahNumber, to: AyahNumber?, repeatVerses: Bool) func shareText(_ lines: [String], in sourceView: UIView, at point: CGPoint) - func deleteNotes(_ notes: [Note], verses: [AyahNumber]) async + func deleteNotes(_ notes: [QuranAnnotations.Note], verses: [AyahNumber]) async func showTranslation(_ verses: [AyahNumber]) #if QURAN_SYNC func addSyncedNote(verses: [AyahNumber]) #endif - func editNote(_ note: Note) + func editNote(_ note: QuranAnnotations.Note) } // MARK: - ViewModel @@ -43,11 +46,15 @@ final class AyahMenuViewModel { let sourceView: UIView let pointInView: CGPoint let verses: [AyahNumber] - let notes: [Note] + let notes: [QuranAnnotations.Note] let noteService: NoteService let textRetriever: ShareableVerseTextRetriever - let usesSyncedNotes: Bool - let noteCount: Int + let highlightColor: HighlightColor? + #if QURAN_SYNC + let usesSyncedNotes: Bool + let noteCount: Int + let ayahBookmarkCollectionService: AyahBookmarkCollectionService? + #endif let quranContentStatePreferences = QuranContentStatePreferences.shared } @@ -65,7 +72,17 @@ final class AyahMenuViewModel { deps.quranContentStatePreferences.quranMode == .translation } - var highlightingColor: Note.Color { deps.noteService.color(from: deps.notes) } + var highlightingColor: HighlightColor { + #if QURAN_SYNC + if deps.ayahBookmarkCollectionService != nil { + return deps.highlightColor ?? .yellow + } + #endif + if let highlightColor = deps.highlightColor { + return highlightColor + } + return highlightColor(for: deps.noteService.color(from: deps.notes)) + } var playSubtitle: String { if deps.verses.count > 1 { // multiple verses selected @@ -105,9 +122,12 @@ final class AyahMenuViewModel { var noteState: AyahMenuUI.NoteState { #if QURAN_SYNC if deps.usesSyncedNotes { - return .noHighlight + return deps.highlightColor == nil ? .noHighlight : .highlighted } #endif + if deps.highlightColor != nil { + return containsText(deps.notes) ? .noted : .highlighted + } if deps.notes.isEmpty { return .noHighlight } else if containsText(deps.notes) { @@ -138,6 +158,16 @@ final class AyahMenuViewModel { func deleteNotes() async { logger.info("AyahMenu: delete notes. Verses: \(deps.verses)") listener?.dismissAyahMenu() + #if QURAN_SYNC + if deps.highlightColor != nil, !containsText(deps.notes), deps.ayahBookmarkCollectionService != nil { + do { + try await removeSyncedHighlights() + } catch { + crasher.recordError(error, reason: "Failed to remove synced highlights") + } + return + } + #endif await listener?.deleteNotes(deps.notes, verses: deps.verses) } @@ -150,13 +180,13 @@ final class AyahMenuViewModel { } #endif let notes = deps.notes - let color = deps.noteService.color(from: notes) - if let note = await _updateHighlight(color: color) { + let color = highlightColor(for: deps.noteService.color(from: notes)) + if let note = await updateLegacyHighlight(color: color) { listener?.editNote(note) } } - func updateHighlight(color: Note.Color) async { + func updateHighlight(color: HighlightColor) async { logger.info("AyahMenu: update verse highlights. Verses: \(deps.verses)") listener?.dismissAyahMenu() _ = await _updateHighlight(color: color) @@ -196,17 +226,33 @@ final class AyahMenuViewModel { // MARK: - Helper - private func containsText(_ notes: [Note]) -> Bool { + private func containsText(_ notes: [QuranAnnotations.Note]) -> Bool { notes.contains { note in !(note.note ?? "").isEmpty } } - private func _updateHighlight(color: Note.Color) async -> Note? { + private func _updateHighlight(color: HighlightColor) async -> QuranAnnotations.Note? { + #if QURAN_SYNC + if deps.ayahBookmarkCollectionService != nil { + do { + try await updateSyncedHighlight(color: color) + return nil + } catch { + crasher.recordError(error, reason: "Failed to update synced highlights") + return nil + } + } + #endif + + return await updateLegacyHighlight(color: color) + } + + private func updateLegacyHighlight(color: HighlightColor) async -> QuranAnnotations.Note? { let quran = ReadingPreferences.shared.reading.quran do { let updatedNote = try await deps.noteService.updateHighlight( - verses: deps.verses, color: color, quran: quran + verses: deps.verses, color: legacyColor(for: color), quran: quran ) logger.info("AyahMenu: notes updated") return updatedNote @@ -221,4 +267,116 @@ final class AyahMenuViewModel { try await deps.textRetriever.textForVerses(deps.verses) } } + + private func highlightColor(for color: QuranAnnotations.Note.Color) -> HighlightColor { + switch color { + case .red: return .red + case .green: return .green + case .blue: return .blue + case .yellow: return .yellow + case .purple: return .purple + } + } + + private func legacyColor(for color: HighlightColor) -> QuranAnnotations.Note.Color { + switch color { + case .red: return .red + case .green: return .green + case .blue: return .blue + case .yellow: return .yellow + case .purple: return .purple + } + } + + #if QURAN_SYNC + private func updateSyncedHighlight(color: HighlightColor) async throws { + guard let ayahBookmarkCollectionService = deps.ayahBookmarkCollectionService else { + return + } + let (targetCollection, collections) = try await highlightCollection(for: color, using: ayahBookmarkCollectionService) + let selectedAyahs = Set(deps.verses.map(AyahKey.init)) + var targetAyahs = Set() + + for collection in collections where collection.highlightColor != nil { + for bookmark in collection.bookmarks where selectedAyahs.contains(AyahKey(bookmark)) { + if collection.collection.localId == targetCollection.collection.localId { + targetAyahs.insert(AyahKey(bookmark)) + } else { + try await ayahBookmarkCollectionService.removeBookmarkFromCollection(bookmark) + } + } + } + + for verse in deps.verses where !targetAyahs.contains(AyahKey(verse)) { + try await ayahBookmarkCollectionService.addAyahBookmarkToCollection( + collectionLocalId: targetCollection.collection.localId, + ayah: verse + ) + } + } + + private func removeSyncedHighlights() async throws { + guard let ayahBookmarkCollectionService = deps.ayahBookmarkCollectionService else { + return + } + let selectedAyahs = Set(deps.verses.map(AyahKey.init)) + for collection in try await ayahBookmarkCollectionService.collectionsFirstValue() where collection.highlightColor != nil { + for bookmark in collection.bookmarks where selectedAyahs.contains(AyahKey(bookmark)) { + try await ayahBookmarkCollectionService.removeBookmarkFromCollection(bookmark) + } + } + } + + private func highlightCollection( + for color: HighlightColor, + using ayahBookmarkCollectionService: AyahBookmarkCollectionService + ) async throws -> (AyahBookmarkCollection, [AyahBookmarkCollection]) { + let collections = try await ayahBookmarkCollectionService.collectionsFirstValue() + if let collection = collections.first(where: { $0.collection.name == color.collectionName }) { + return (collection, collections) + } + + try await ayahBookmarkCollectionService.createCollection(named: color.collectionName) + let updatedCollections = try await ayahBookmarkCollectionService.collectionsFirstValue() + guard let collection = updatedCollections.first(where: { $0.collection.name == color.collectionName }) else { + throw SyncedHighlightCollectionError.collectionUnavailable + } + return (collection, updatedCollections) + } + #endif } + +#if QURAN_SYNC + + private extension AyahBookmarkCollectionService { + func collectionsFirstValue() async throws -> [AyahBookmarkCollection] { + var iterator = collectionsSequence().makeAsyncIterator() + return try await iterator.next() ?? [] + } + } + + private extension AyahBookmarkCollection { + var highlightColor: HighlightColor? { + HighlightColor(collectionName: collection.name) + } + } + + private struct AyahKey: Hashable { + init(_ bookmark: AyahCollectionBookmark) { + sura = Int32(bookmark.ayah.sura.suraNumber) + ayah = Int32(bookmark.ayah.ayah) + } + + init(_ ayahNumber: AyahNumber) { + sura = Int32(ayahNumber.sura.suraNumber) + ayah = Int32(ayahNumber.ayah) + } + + let sura: Int32 + let ayah: Int32 + } + + private enum SyncedHighlightCollectionError: Error { + case collectionUnavailable + } +#endif diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift index 90db0fde5..b514712f5 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift @@ -13,19 +13,36 @@ struct AyahBookmarkCollectionsBuilder { init( ayahBookmarkCollectionService: AyahBookmarkCollectionService, - navigateToPage: @escaping (Page) -> Void + includedCollectionNames: Set? = nil, + prepareCollections: @escaping ([AyahBookmarkCollection]) async throws -> Void = { _ in }, + navigateToPage: @escaping (Page) -> Void, + title: String = l("bookmarks.collections"), + allowsCollectionManagement: Bool = true, + allowsBookmarkDeletion: Bool = true ) { self.ayahBookmarkCollectionService = ayahBookmarkCollectionService + self.includedCollectionNames = includedCollectionNames + self.prepareCollections = prepareCollections self.navigateToPage = navigateToPage + self.title = title + self.allowsCollectionManagement = allowsCollectionManagement + self.allowsBookmarkDeletion = allowsBookmarkDeletion } func build() -> UIViewController { let viewModel = AyahBookmarkCollectionsViewModel( ayahBookmarkCollectionService: ayahBookmarkCollectionService, - excludedCollectionNames: [Self.oldPageBookmarksCollectionName], + includedCollectionNames: includedCollectionNames, + excludedCollectionNames: includedCollectionNames == nil ? [Self.oldPageBookmarksCollectionName] : [], + prepareCollections: prepareCollections, navigateToPage: navigateToPage ) - return AyahBookmarkCollectionsViewController(viewModel: viewModel) + return AyahBookmarkCollectionsViewController( + viewModel: viewModel, + title: title, + allowsCollectionManagement: allowsCollectionManagement, + allowsBookmarkDeletion: allowsBookmarkDeletion + ) } func buildOldPageBookmarks() -> UIViewController { @@ -45,6 +62,11 @@ private static let oldPageBookmarksCollectionName = "Old Page Bookmarks" private let ayahBookmarkCollectionService: AyahBookmarkCollectionService + private let includedCollectionNames: Set? + private let prepareCollections: ([AyahBookmarkCollection]) async throws -> Void private let navigateToPage: (Page) -> Void + private let title: String + private let allowsCollectionManagement: Bool + private let allowsBookmarkDeletion: Bool } #endif diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift index b3c7ae676..88772ad69 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift @@ -7,6 +7,7 @@ import Localization import NoorUI + import QuranAnnotations import SwiftUI import UIx @@ -82,12 +83,15 @@ set: { viewModel.setCollection(collection, expanded: $0) } ) + let highlightColor = HighlightColor(collectionName: collection.collection.name) + let allowsCollectionDeletion = allowsCollectionManagement && highlightColor == nil + NoorEditableCollapsibleSection( - title: collection.collection.name, + title: highlightColor?.localizedName ?? collection.collection.name, isExpanded: isExpanded, collection.bookmarks, - showsHeaderDeleteAction: allowsCollectionManagement && viewModel.editMode.isEditing, - headerDeleteAction: deleteCollectionAction(for: collection) + showsHeaderDeleteAction: allowsCollectionDeletion && viewModel.editMode.isEditing, + headerDeleteAction: allowsCollectionDeletion ? deleteCollectionAction(for: collection) : nil ) { bookmark in bookmarkItem(bookmark) } diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift index 3d3657933..2cc7a00d8 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift @@ -6,6 +6,7 @@ // import Foundation + import QuranAnnotations import QuranKit import SwiftUI import VLogging @@ -18,11 +19,13 @@ ayahBookmarkCollectionService: AyahBookmarkCollectionService, includedCollectionNames: Set? = nil, excludedCollectionNames: Set = [], + prepareCollections: @escaping ([AyahBookmarkCollection]) async throws -> Void = { _ in }, navigateToPage: @escaping (Page) -> Void ) { self.ayahBookmarkCollectionService = ayahBookmarkCollectionService self.includedCollectionNames = includedCollectionNames self.excludedCollectionNames = excludedCollectionNames + self.prepareCollections = prepareCollections self.navigateToPage = navigateToPage } @@ -33,13 +36,27 @@ @Published var collections: [AyahBookmarkCollection] = [] @Published var collapsedCollectionIDs: Set = [] + nonisolated static func sorted(_ collections: [AyahBookmarkCollection]) -> [AyahBookmarkCollection] { + collections.sorted { lhs, rhs in + switch (highlightSortIndex(lhs), highlightSortIndex(rhs)) { + case let (lhsIndex?, rhsIndex?): + return lhsIndex < rhsIndex + case (_?, nil): + return true + case (nil, _?): + return false + case (nil, nil): + return lhs.collection.name.localizedCaseInsensitiveCompare(rhs.collection.name) == .orderedAscending + } + } + } + func start() async { do { let sequence = ayahBookmarkCollectionService.collectionsSequence() for try await collections in sequence { - self.collections = filtered(collections).sorted { - $0.collection.name.localizedCaseInsensitiveCompare($1.collection.name) == .orderedAscending - } + self.collections = Self.sorted(filtered(collections)) + try await prepareCollectionsIfNeeded(collections) } } catch { self.error = error @@ -93,7 +110,16 @@ private let ayahBookmarkCollectionService: AyahBookmarkCollectionService private let includedCollectionNames: Set? private let excludedCollectionNames: Set + private let prepareCollections: ([AyahBookmarkCollection]) async throws -> Void private let navigateToPage: (Page) -> Void + private var didPrepareCollections = false + + private nonisolated static func highlightSortIndex(_ collection: AyahBookmarkCollection) -> Int? { + guard let color = HighlightColor(collectionName: collection.collection.name) else { + return nil + } + return HighlightColor.allCases.firstIndex(of: color) + } private func filtered(_ collections: [AyahBookmarkCollection]) -> [AyahBookmarkCollection] { collections.filter { collection in @@ -104,5 +130,13 @@ return !excludedCollectionNames.contains(name) } } + + private func prepareCollectionsIfNeeded(_ collections: [AyahBookmarkCollection]) async throws { + guard !didPrepareCollections else { + return + } + didPrepareCollections = true + try await prepareCollections(collections) + } } #endif diff --git a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift index 1093eb4e0..05e5f13ef 100644 --- a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift +++ b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift @@ -9,6 +9,8 @@ import AnnotationsService import AppDependencies import FeaturesSupport +import Localization +import QuranKit import UIKit @MainActor @@ -26,13 +28,20 @@ public struct BookmarksBuilder { #if QURAN_SYNC let showCollectionsAction: (@MainActor (UIViewController) async -> Void)? let showOldPageBookmarksAction: (@MainActor (UIViewController) async -> Void)? + let readingBookmarkService: ReadingBookmarkService? if let syncService = container.syncService { + readingBookmarkService = ReadingBookmarkService(syncService: syncService) let ayahBookmarkCollectionService = AyahBookmarkCollectionService(syncService: syncService) + let prepareHighlightCollections: ([AyahBookmarkCollection]) async throws -> Void = { collections in + try await HighlightBookmarkCollections.ensure(in: collections, using: ayahBookmarkCollectionService) + } + let navigateToPage: (Page) -> Void = { [weak listener] page in + listener?.navigateTo(page: page, lastPage: nil, highlightingSearchAyah: nil) + } let collectionsBuilder = AyahBookmarkCollectionsBuilder( ayahBookmarkCollectionService: ayahBookmarkCollectionService, - navigateToPage: { [weak listener] page in - listener?.navigateTo(page: page, lastPage: nil, highlightingSearchAyah: nil) - } + prepareCollections: prepareHighlightCollections, + navigateToPage: navigateToPage ) showCollectionsAction = { presenter in guard let navigationController = presenter.navigationController else { @@ -49,6 +58,7 @@ public struct BookmarksBuilder { navigationController.pushViewController(oldPageBookmarksViewController, animated: true) } } else { + readingBookmarkService = nil showCollectionsAction = nil showOldPageBookmarksAction = nil } @@ -59,6 +69,7 @@ public struct BookmarksBuilder { navigateTo: { [weak listener] page in listener?.navigateTo(page: page, lastPage: nil, highlightingSearchAyah: nil) }, + readingBookmarkService: readingBookmarkService, showCollectionsAction: showCollectionsAction, showOldPageBookmarksAction: showOldPageBookmarksAction ) diff --git a/Features/BookmarksFeature/Sources/BookmarksView.swift b/Features/BookmarksFeature/Sources/BookmarksView.swift index 1ff391868..59df6c0f4 100644 --- a/Features/BookmarksFeature/Sources/BookmarksView.swift +++ b/Features/BookmarksFeature/Sources/BookmarksView.swift @@ -5,6 +5,7 @@ // Created by Mohamed Afifi on 2023-07-13. // +import AnnotationsService import Localization import NoorUI import QuranAnnotations @@ -25,9 +26,11 @@ struct BookmarksView: View { editMode: $viewModel.editMode, error: $viewModel.error, bookmarks: viewModel.bookmarks, + readingBookmark: viewModel.readingBookmark, shouldShowSyncBanner: viewModel.shouldShowSyncBanner, start: { await viewModel.start() }, selectAction: { viewModel.navigateTo($0) }, + selectReadingBookmark: { viewModel.navigateToReadingBookmark() }, deleteAction: { await viewModel.deleteItem($0) }, dismissSyncBanner: { viewModel.dismissSyncBanner() }, signInAction: { await viewModel.loginToQuranCom() }, @@ -45,10 +48,12 @@ private struct BookmarksViewUI: View { @Binding var error: Error? let bookmarks: [PageBookmark] + let readingBookmark: QuranReadingBookmark? let shouldShowSyncBanner: Bool let start: AsyncAction let selectAction: ItemAction + let selectReadingBookmark: () -> Void let deleteAction: AsyncItemAction let dismissSyncBanner: () -> Void let signInAction: @MainActor () async -> Void @@ -57,11 +62,11 @@ private struct BookmarksViewUI: View { var body: some View { Group { - if bookmarks.isEmpty { + if showsEmptyState { emptyState } else { NoorList { - listSections(includeBookmarks: true) + listSections(includeBookmarks: !bookmarks.isEmpty) } } } @@ -74,6 +79,14 @@ private struct BookmarksViewUI: View { // MARK: Private + private var showsEmptyState: Bool { + #if QURAN_SYNC + bookmarks.isEmpty && readingBookmark == nil + #else + bookmarks.isEmpty + #endif + } + private var emptyState: some View { VStack(spacing: 16) { #if QURAN_SYNC @@ -130,6 +143,12 @@ private struct BookmarksViewUI: View { } } + if let readingBookmark { + NoorBasicSection(title: l("reading-bookmark.my-title")) { + readingBookmarkItem(readingBookmark) + } + } + if showOldPageBookmarksAction != nil { NoorBasicSection { oldPageBookmarksRow @@ -151,6 +170,20 @@ private struct BookmarksViewUI: View { } } + #if QURAN_SYNC + private func readingBookmarkItem(_ bookmark: QuranReadingBookmark) -> some View { + let ayah = bookmark.ayah + return NoorListItem( + image: .init(.bookmark, color: .red), + title: "\(ayah.sura.localizedName()) \(sura: ayah.sura.arabicSuraName)", + subtitle: .init(text: bookmark.lastUpdated.timeAgo(), location: .bottom), + accessory: .text(NumberFormatter.shared.format(bookmark.page.pageNumber)) + ) { + selectReadingBookmark() + } + } + #endif + private func listItem(_ bookmark: PageBookmark) -> some View { let ayah = bookmark.page.firstVerse return NoorListItem( @@ -241,9 +274,11 @@ struct BookmarksView_Previews: PreviewProvider { editMode: $editMode, error: $error, bookmarks: items, + readingBookmark: .page(Quran.hafsMadani1405.pages[0], .distantPast), shouldShowSyncBanner: true, start: {}, selectAction: { _ in }, + selectReadingBookmark: {}, deleteAction: { item in items = items.filter { $0 != item } }, dismissSyncBanner: {}, signInAction: {}, diff --git a/Features/BookmarksFeature/Sources/BookmarksViewModel.swift b/Features/BookmarksFeature/Sources/BookmarksViewModel.swift index f3d4a1909..67db9b05d 100644 --- a/Features/BookmarksFeature/Sources/BookmarksViewModel.swift +++ b/Features/BookmarksFeature/Sources/BookmarksViewModel.swift @@ -26,6 +26,7 @@ final class BookmarksViewModel: ObservableObject { service: PageBookmarkService, authenticationClient: (any AuthenticationClient)?, navigateTo: @escaping (Page) -> Void, + readingBookmarkService: ReadingBookmarkService? = nil, showCollectionsAction: (@MainActor (UIViewController) async -> Void)? = nil, showOldPageBookmarksAction: (@MainActor (UIViewController) async -> Void)? = nil ) { @@ -33,6 +34,7 @@ final class BookmarksViewModel: ObservableObject { self.service = service self.authenticationClient = authenticationClient self.navigateTo = navigateTo + self.readingBookmarkService = readingBookmarkService presentCollectionsAction = showCollectionsAction presentOldPageBookmarksAction = showOldPageBookmarksAction isSyncBannerDismissed = preferences.isSyncBannerDismissed @@ -43,6 +45,7 @@ final class BookmarksViewModel: ObservableObject { @Published var editMode: EditMode = .inactive @Published var error: Error? = nil @Published var bookmarks: [PageBookmark] = [] + @Published var readingBookmark: QuranReadingBookmark? @Published var isAuthenticated: Bool = false @Published var isSyncBannerDismissed: Bool @@ -77,18 +80,22 @@ final class BookmarksViewModel: ObservableObject { isAuthenticated = false } - let bookmarksSequence = readingPreferences.$reading - .prepend(readingPreferences.reading) - .map { [service] reading in - service.pageBookmarks(quran: reading.quran) - } - .switchToLatest() - .values() + #if QURAN_SYNC + async let readingBookmark: () = loadReadingBookmark() + await loadBookmarks() + _ = await readingBookmark + #else + await loadBookmarks() + #endif + } - for await bookmarks in bookmarksSequence { - self.bookmarks = bookmarks - .sorted { $0.creationDate > $1.creationDate } + func navigateToReadingBookmark() { + guard let readingBookmark else { + return } + logger.info("Bookmarks: select reading bookmark at \(readingBookmark.page)") + analytics.openingQuran(from: .bookmarks) + navigateTo(readingBookmark.page) } func navigateTo(_ item: PageBookmark) { @@ -155,12 +162,45 @@ final class BookmarksViewModel: ObservableObject { private let navigateTo: (Page) -> Void private let analytics: AnalyticsLibrary private let service: PageBookmarkService + private let readingBookmarkService: ReadingBookmarkService? private let authenticationClient: (any AuthenticationClient)? private let presentCollectionsAction: (@MainActor (UIViewController) async -> Void)? private let presentOldPageBookmarksAction: (@MainActor (UIViewController) async -> Void)? private let readingPreferences = ReadingPreferences.shared private let preferences = BookmarksPreferences.shared + private func loadBookmarks() async { + let bookmarksSequence = readingPreferences.$reading + .prepend(readingPreferences.reading) + .map { [service] reading in + service.pageBookmarks(quran: reading.quran) + } + .switchToLatest() + .values() + + for await bookmarks in bookmarksSequence { + self.bookmarks = bookmarks + .sorted { $0.creationDate > $1.creationDate } + } + } + + #if QURAN_SYNC + private func loadReadingBookmark() async { + guard let readingBookmarkService else { + return + } + + do { + let sequence = readingBookmarkService.readingBookmarkSequence() + for try await bookmark in sequence { + readingBookmark = bookmark + } + } catch { + self.error = error + } + } + #endif + private func requireAuthenticationClient() throws -> any AuthenticationClient { guard let authenticationClient else { throw AuthenticationClientError.clientIsNotAuthenticated(nil) diff --git a/Features/BookmarksFeature/Sources/HighlightBookmarkCollections.swift b/Features/BookmarksFeature/Sources/HighlightBookmarkCollections.swift new file mode 100644 index 000000000..5f47f9b0a --- /dev/null +++ b/Features/BookmarksFeature/Sources/HighlightBookmarkCollections.swift @@ -0,0 +1,20 @@ +#if QURAN_SYNC + // + // HighlightBookmarkCollections.swift + // + // Created by Ahmed Nabil on 2026-05-10. + // + + import QuranAnnotations + + enum HighlightBookmarkCollections { + static let names = HighlightColor.allCases.map(\.collectionName) + + static func ensure(in collections: [AyahBookmarkCollection], using service: AyahBookmarkCollectionService) async throws { + let existingNames = Set(collections.map(\.collection.name)) + for name in names where !existingNames.contains(name) { + try await service.createCollection(named: name) + } + } + } +#endif diff --git a/Features/BookmarksFeature/Tests/AyahBookmarkCollectionsViewModelTests.swift b/Features/BookmarksFeature/Tests/AyahBookmarkCollectionsViewModelTests.swift new file mode 100644 index 000000000..96595a642 --- /dev/null +++ b/Features/BookmarksFeature/Tests/AyahBookmarkCollectionsViewModelTests.swift @@ -0,0 +1,34 @@ +#if QURAN_SYNC + import MobileSync + import XCTest + @testable import BookmarksFeature + + final class AyahBookmarkCollectionsViewModelTests: XCTestCase { + func test_sorted_groupsHighlightCollectionsBeforeUserCollections() { + let collections = AyahBookmarkCollectionsViewModel.sorted([ + collection(name: "Z Collection"), + collection(name: "blue"), + collection(name: "A Collection"), + collection(name: "red"), + ]) + + XCTAssertEqual(collections.map(\.collection.name), [ + "red", + "blue", + "A Collection", + "Z Collection", + ]) + } + + private func collection(name: String) -> AyahBookmarkCollection { + AyahBookmarkCollection( + collection: Collection_( + name: name, + lastUpdated: .distantPast, + localId: name + ), + bookmarks: [] + ) + } + } +#endif diff --git a/Features/BookmarksFeature/Tests/HighlightCollectionsSyncTests.swift b/Features/BookmarksFeature/Tests/HighlightCollectionsSyncTests.swift new file mode 100644 index 000000000..022e788ae --- /dev/null +++ b/Features/BookmarksFeature/Tests/HighlightCollectionsSyncTests.swift @@ -0,0 +1,24 @@ +#if QURAN_SYNC + import QuranAnnotations + import XCTest + + final class HighlightCollectionsSyncTests: XCTestCase { + func test_highlightColor_matchesFixedCollectionNames() { + XCTAssertEqual(HighlightColor(collectionName: "red"), .red) + XCTAssertEqual(HighlightColor(collectionName: "green"), .green) + XCTAssertEqual(HighlightColor(collectionName: "blue"), .blue) + XCTAssertEqual(HighlightColor(collectionName: "yellow"), .yellow) + XCTAssertEqual(HighlightColor(collectionName: "purple"), .purple) + XCTAssertNil(HighlightColor(collectionName: "Red")) + XCTAssertNil(HighlightColor(collectionName: "bookmarks")) + } + + func test_collectionName_matchesFixedRawValue() { + XCTAssertEqual(HighlightColor.red.collectionName, "red") + XCTAssertEqual(HighlightColor.green.collectionName, "green") + XCTAssertEqual(HighlightColor.blue.collectionName, "blue") + XCTAssertEqual(HighlightColor.yellow.collectionName, "yellow") + XCTAssertEqual(HighlightColor.purple.collectionName, "purple") + } + } +#endif diff --git a/Features/HomeFeature/HomeBuilder.swift b/Features/HomeFeature/HomeBuilder.swift index 9701c43fc..81446a09b 100644 --- a/Features/HomeFeature/HomeBuilder.swift +++ b/Features/HomeFeature/HomeBuilder.swift @@ -31,6 +31,7 @@ public struct HomeBuilder { let viewModel = HomeViewModel( lastPageService: container.lastPageService(), textRetriever: textRetriever, + readingBookmarkService: readingBookmarkService(), navigateToPage: { [weak listener] lastPage in listener?.navigateTo(page: lastPage, lastPage: lastPage, highlightingSearchAyah: nil) }, @@ -51,4 +52,14 @@ public struct HomeBuilder { // MARK: Internal let container: AppDependencies + + // MARK: Private + + private func readingBookmarkService() -> ReadingBookmarkService? { + #if QURAN_SYNC + container.syncService.map { ReadingBookmarkService(syncService: $0) } + #else + nil + #endif + } } diff --git a/Features/HomeFeature/HomeView.swift b/Features/HomeFeature/HomeView.swift index 24b89b981..2c221d6e3 100644 --- a/Features/HomeFeature/HomeView.swift +++ b/Features/HomeFeature/HomeView.swift @@ -5,6 +5,7 @@ // Created by Mohamed Afifi on 2023-07-16. // +import AnnotationsService import Localization import NoorUI import QuranAnnotations @@ -19,10 +20,12 @@ struct HomeView: View { HomeViewUI( type: viewModel.type, lastPages: viewModel.lastPages, + readingBookmark: viewModel.readingBookmark, suras: viewModel.suras, quarters: viewModel.quarters, start: { await viewModel.start() }, selectLastPage: { viewModel.navigateTo($0) }, + selectReadingBookmark: { viewModel.navigateToReadingBookmark() }, selectSura: { viewModel.navigateTo($0) }, selectQuarter: { viewModel.navigateTo($0) }, surahSortOrder: viewModel.surahSortOrder, @@ -35,12 +38,14 @@ struct HomeView: View { private struct HomeViewUI: View { let type: HomeViewType let lastPages: [LastPage] + let readingBookmark: QuranReadingBookmark? let suras: [Sura] let quarters: [QuarterItem] let start: AsyncAction let selectLastPage: ItemAction + let selectReadingBookmark: () -> Void let selectSura: ItemAction let selectQuarter: ItemAction let surahSortOrder: SurahSortOrder @@ -49,6 +54,14 @@ private struct HomeViewUI: View { var body: some View { NoorList { + #if QURAN_SYNC + if let readingBookmark { + NoorBasicSection(title: l("reading-bookmark.my-title")) { + readingBookmarkView(readingBookmark) + } + } + #endif + NoorSection(title: lAndroid("recent_pages"), lastPages) { lastPage in lastPageView(lastPage) } @@ -67,6 +80,20 @@ private struct HomeViewUI: View { .task { await start() } } + #if QURAN_SYNC + func readingBookmarkView(_ bookmark: QuranReadingBookmark) -> some View { + let ayah = bookmark.ayah + return NoorListItem( + image: .init(.bookmark, color: .red), + title: "\(ayah.sura.localizedName()) \(sura: ayah.sura.arabicSuraName)", + subtitle: .init(text: bookmark.lastUpdated.timeAgo(), location: .bottom), + accessory: .text(NumberFormatter.shared.format(bookmark.page.pageNumber)) + ) { + selectReadingBookmark() + } + } + #endif + func lastPageView(_ lastPage: LastPage) -> some View { let ayah = lastPage.page.firstVerse return NoorListItem( @@ -169,10 +196,12 @@ struct HomeView_Previews: PreviewProvider { HomeViewUI( type: type, lastPages: lastPages, + readingBookmark: .page(Quran.hafsMadani1405.pages[0], .distantPast), suras: quran.suras, quarters: quran.quarters.map { QuarterItem(quarter: $0, ayahText: Self.ayahText) }, start: {}, selectLastPage: { _ in }, + selectReadingBookmark: {}, selectSura: { _ in }, selectQuarter: { _ in }, surahSortOrder: .ascending, diff --git a/Features/HomeFeature/HomeViewModel.swift b/Features/HomeFeature/HomeViewModel.swift index 84c1b1b77..bda209edf 100644 --- a/Features/HomeFeature/HomeViewModel.swift +++ b/Features/HomeFeature/HomeViewModel.swift @@ -34,12 +34,14 @@ final class HomeViewModel: ObservableObject { init( lastPageService: any LastPageService, textRetriever: QuranTextDataService, + readingBookmarkService: ReadingBookmarkService? = nil, navigateToPage: @escaping (Page) -> Void, navigateToSura: @escaping (Sura) -> Void, navigateToQuarter: @escaping (Quarter) -> Void ) { self.lastPageService = lastPageService self.textRetriever = textRetriever + self.readingBookmarkService = readingBookmarkService self.navigateToPage = navigateToPage self.navigateToSura = navigateToSura self.navigateToQuarter = navigateToQuarter @@ -53,6 +55,7 @@ final class HomeViewModel: ObservableObject { @Published var suras: [Sura] = [] @Published var quarters: [QuarterItem] = [] @Published var lastPages: [LastPage] = [] + @Published var readingBookmark: QuranReadingBookmark? @Published var surahSortOrder: SurahSortOrder = HomePreferences.shared.surahSortOrder @@ -80,13 +83,25 @@ final class HomeViewModel: ObservableObject { async let lastPages: () = loadLastPages() async let suras: () = loadSuras() async let quarters: () = loadQuarters() - _ = await [lastPages, suras, quarters] + #if QURAN_SYNC + async let readingBookmark: () = loadReadingBookmark() + _ = await [lastPages, suras, quarters, readingBookmark] + #else + _ = await [lastPages, suras, quarters] + #endif } func navigateTo(_ lastPage: Page) { navigateToPage(lastPage) } + func navigateToReadingBookmark() { + guard let readingBookmark else { + return + } + navigateToPage(readingBookmark.page) + } + func navigateTo(_ sura: Sura) { navigateToSura(sura) } @@ -103,6 +118,7 @@ final class HomeViewModel: ObservableObject { private let lastPageService: any LastPageService private let textRetriever: QuranTextDataService + private let readingBookmarkService: ReadingBookmarkService? private let navigateToPage: (Page) -> Void private let navigateToSura: (Sura) -> Void private let navigateToQuarter: (Quarter) -> Void @@ -122,6 +138,23 @@ final class HomeViewModel: ObservableObject { } } + #if QURAN_SYNC + private func loadReadingBookmark() async { + guard let readingBookmarkService else { + return + } + + do { + let sequence = readingBookmarkService.readingBookmarkSequence() + for try await bookmark in sequence { + readingBookmark = bookmark + } + } catch { + crasher.recordError(error, reason: "Failed to load reading bookmark") + } + } + #endif + private func loadSuras() async { let readings = readingPreferences.$reading .prepend(readingPreferences.reading) diff --git a/Features/QuranViewFeature/QuranBuilder.swift b/Features/QuranViewFeature/QuranBuilder.swift index 84637028f..21f965a3d 100644 --- a/Features/QuranViewFeature/QuranBuilder.swift +++ b/Features/QuranViewFeature/QuranBuilder.swift @@ -10,6 +10,9 @@ import AnnotationsService import AppDependencies import AudioBannerFeature import AyahMenuFeature +#if QURAN_SYNC + import BookmarksFeature +#endif import MoreMenuFeature import NoteEditorFeature import QuranContentFeature @@ -44,11 +47,18 @@ public struct QuranBuilder { analytics: container.analytics ) } + let syncedHighlightsObserver = container.syncService.map { + QuranSyncedHighlightsObserver(ayahBookmarkCollectionService: AyahBookmarkCollectionService(syncService: $0), highlightsService: highlightsService) + } + let readingBookmarkService = container.syncService.map { + ReadingBookmarkService(syncService: $0) + } let interactorDeps = QuranInteractor.Deps( quran: quran, analytics: container.analytics, pageBookmarkService: pageBookmarkService, noteService: container.noteService(), + highlightsService: highlightsService, ayahMenuBuilder: AyahMenuBuilder(container: container), moreMenuBuilder: MoreMenuBuilder(), audioBannerBuilder: AudioBannerBuilder(container: container), @@ -59,7 +69,9 @@ public struct QuranBuilder { translationVerseBuilder: TranslationVerseBuilder(container: container), resources: container.readingResources, syncedNoteService: syncedNoteService, - syncedNoteEditorBuilder: syncedNoteEditorBuilder + syncedNoteEditorBuilder: syncedNoteEditorBuilder, + syncedHighlightsObserver: syncedHighlightsObserver, + readingBookmarkService: readingBookmarkService ) #else let interactorDeps = QuranInteractor.Deps( @@ -67,6 +79,7 @@ public struct QuranBuilder { analytics: container.analytics, pageBookmarkService: pageBookmarkService, noteService: container.noteService(), + highlightsService: highlightsService, ayahMenuBuilder: AyahMenuBuilder(container: container), moreMenuBuilder: MoreMenuBuilder(), audioBannerBuilder: AudioBannerBuilder(container: container), diff --git a/Features/QuranViewFeature/QuranInteractor.swift b/Features/QuranViewFeature/QuranInteractor.swift index 5798953a7..d3a4f60cb 100644 --- a/Features/QuranViewFeature/QuranInteractor.swift +++ b/Features/QuranViewFeature/QuranInteractor.swift @@ -52,6 +52,11 @@ protocol QuranPresentable: UIViewController { func dismissWordPointer(_ viewController: UIViewController) func dismissPresentedViewController(completion: (() -> Void)?) + + #if QURAN_SYNC + func showReadingBookmarkNudge(expanded: Bool, undo: @escaping () async -> Void) + func hideReadingBookmarkNudge() + #endif } @MainActor @@ -63,6 +68,7 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis let analytics: AnalyticsLibrary let pageBookmarkService: PageBookmarkService let noteService: NoteService + let highlightsService: QuranHighlightsService let ayahMenuBuilder: AyahMenuBuilder let moreMenuBuilder: MoreMenuBuilder let audioBannerBuilder: AudioBannerBuilder @@ -75,6 +81,8 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis #if QURAN_SYNC let syncedNoteService: MobileSyncNoteService? let syncedNoteEditorBuilder: SyncedNoteEditorBuilder? + let syncedHighlightsObserver: QuranSyncedHighlightsObserver? + let readingBookmarkService: ReadingBookmarkService? #endif } @@ -89,6 +97,7 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis deinit { #if QURAN_SYNC syncedNotesObservationTask?.cancel() + readingBookmarkTask?.cancel() #endif } @@ -110,6 +119,7 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis func start() { #if QURAN_SYNC + deps.syncedHighlightsObserver?.start() if let syncedNoteService = deps.syncedNoteService { startSyncedNotesObservation(syncedNoteService) } else { @@ -119,10 +129,15 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis startLegacyNotesObservation() #endif - deps.pageBookmarkService.pageBookmarks(quran: deps.quran) - .receive(on: DispatchQueue.main) - .sink { [weak self] in self?.pageBookmarks = $0 } - .store(in: &cancellables) + #if QURAN_SYNC + if let readingBookmarkService = deps.readingBookmarkService { + startReadingBookmarkObservation(readingBookmarkService) + } else { + startPageBookmarkObservation() + } + #else + startPageBookmarkObservation() + #endif contentStatePreferences.$quranMode .sink { [weak self] _ in self?.onQuranModeUpdated() } @@ -267,7 +282,8 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis pointInView: point, verses: verses, notes: notes, - noteCount: syncedNoteCount(interacting: verses) + noteCount: syncedNoteCount(interacting: verses), + highlightColor: highlightColor(for: verses) ) let ayahMenuViewController = deps.ayahMenuBuilder.build(withListener: self, input: input) presenter?.presentAyahMenu(ayahMenuViewController, in: sourceView, at: point) @@ -311,6 +327,13 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis func toogleBookmark() async { logger.info("Quran: onBookmarkBarButtonTapped") + #if QURAN_SYNC + if let readingBookmarkService = deps.readingBookmarkService { + await toggleReadingBookmark(using: readingBookmarkService) + return + } + #endif + let pages = visiblePages let wasBookmarked = bookmarked(pages) @@ -346,6 +369,12 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis #if QURAN_SYNC private var syncedNotes: [SyncedNote] = [] private var syncedNotesObservationTask: Task? + private var readingBookmarkTask: Task? + private var readingBookmark: QuranReadingBookmark? { + didSet { + reloadPageBookmark() + } + } #endif private var deps: Deps @@ -372,6 +401,13 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis } } + private func startPageBookmarkObservation() { + deps.pageBookmarkService.pageBookmarks(quran: deps.quran) + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.pageBookmarks = $0 } + .store(in: &cancellables) + } + private func startLegacyNotesObservation() { deps.noteService.notes(quran: deps.quran) .receive(on: DispatchQueue.main) @@ -397,6 +433,22 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis } } } + + private func startReadingBookmarkObservation(_ service: ReadingBookmarkService) { + readingBookmarkTask?.cancel() + readingBookmarkTask = Task { [weak self] in + do { + let sequence = service.readingBookmarkSequence() + for try await bookmark in sequence { + await MainActor.run { + self?.readingBookmark = bookmark + } + } + } catch { + crasher.recordError(error, reason: "Failed to observe reading bookmark") + } + } + } #endif private func setVisiblePages(_ pages: [Page]) { @@ -443,6 +495,15 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis #endif } + private func highlightColor(for verses: [AyahNumber]) -> HighlightColor? { + let colors = verses.compactMap { deps.highlightsService.highlights.highlightVerses[$0] } + guard colors.count == verses.count else { + return nil + } + let uniqueColors = Set(colors) + return uniqueColors.count == 1 ? uniqueColors.first : nil + } + private func dismissWordPointer() { logger.info("Quran: dismiss word pointer") guard let viewController = wordPointer else { @@ -480,6 +541,48 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis return (viewController, contentViewModel) } + #if QURAN_SYNC + private func toggleReadingBookmark(using service: ReadingBookmarkService) async { + let pages = visiblePages + guard let page = pages.first else { + return + } + + do { + if readingBookmarked(pages) { + deps.analytics.removeBookmarkPage(page) + try await service.removeReadingBookmark() + readingBookmark = nil + presenter?.hideReadingBookmarkNudge() + } else { + deps.analytics.bookmarkPage(page) + readingBookmark = try await service.addReadingBookmark(page: page) + showReadingBookmarkNudge(using: service) + } + } catch { + crasher.recordError(error, reason: "Failed to toggle reading bookmark") + } + } + + private func showReadingBookmarkNudge(using service: ReadingBookmarkService) { + let isExpanded = !ReadingBookmarkPreferences.shared.isEducationShown + ReadingBookmarkPreferences.shared.isEducationShown = true + presenter?.showReadingBookmarkNudge(expanded: isExpanded) { [weak self] in + await self?.removeReadingBookmark(using: service) + } + } + + private func removeReadingBookmark(using service: ReadingBookmarkService) async { + do { + try await service.removeReadingBookmark() + readingBookmark = nil + presenter?.hideReadingBookmarkNudge() + } catch { + crasher.recordError(error, reason: "Failed to remove reading bookmark") + } + } + #endif + // MARK: - Page Bookmark private func reloadPageBookmark() { @@ -490,10 +593,22 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis } private func bookmarked(_ pages: [Page]) -> Bool { + #if QURAN_SYNC + if deps.readingBookmarkService != nil { + return readingBookmarked(pages) + } + #endif + let visibleBookmarks = pageBookmarks.filter { pages.contains($0.page) } return !visibleBookmarks.isEmpty } + #if QURAN_SYNC + private func readingBookmarked(_ pages: [Page]) -> Bool { + readingBookmark?.isPageBookmark(for: pages) ?? false + } + #endif + private func showPageBookmarkIfNeeded(for pages: [Page]) { presenter?.updateBookmark(bookmarked(pages)) } diff --git a/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift b/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift new file mode 100644 index 000000000..2246ee3ff --- /dev/null +++ b/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift @@ -0,0 +1,68 @@ +#if QURAN_SYNC + // + // QuranSyncedHighlightsObserver.swift + // + // Created by Ahmed Nabil on 2026-05-06. + // + + import AnnotationsService + import BookmarksFeature + import QuranAnnotations + import QuranKit + import VLogging + + @MainActor + final class QuranSyncedHighlightsObserver { + // MARK: Lifecycle + + init(ayahBookmarkCollectionService: AyahBookmarkCollectionService, highlightsService: QuranHighlightsService) { + self.ayahBookmarkCollectionService = ayahBookmarkCollectionService + self.highlightsService = highlightsService + } + + deinit { + task?.cancel() + } + + // MARK: Internal + + func start() { + guard task == nil else { + return + } + let ayahBookmarkCollectionService = ayahBookmarkCollectionService + let highlightsService = highlightsService + task = Task { + do { + let sequence = ayahBookmarkCollectionService.collectionsSequence() + for try await collections in sequence { + var highlights = highlightsService.highlights + highlights.highlightVerses = highlightedAyahs(from: collections) + highlightsService.highlights = highlights + } + } catch { + logger.error("Failed to observe synced highlights: \(error)") + } + } + } + + private func highlightedAyahs(from collections: [AyahBookmarkCollection]) -> [AyahNumber: HighlightColor] { + var highlights: [AyahNumber: HighlightColor] = [:] + for collection in collections { + guard let color = HighlightColor(collectionName: collection.collection.name) else { + continue + } + for bookmark in collection.bookmarks { + highlights[bookmark.ayah] = color + } + } + return highlights + } + + // MARK: Private + + private let ayahBookmarkCollectionService: AyahBookmarkCollectionService + private let highlightsService: QuranHighlightsService + private var task: Task? + } +#endif diff --git a/Features/QuranViewFeature/QuranView.swift b/Features/QuranViewFeature/QuranView.swift index 3044eba0e..be47442f7 100644 --- a/Features/QuranViewFeature/QuranView.swift +++ b/Features/QuranViewFeature/QuranView.swift @@ -17,6 +17,7 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // +import NoorUI import UIKit import ViewConstrainer @@ -70,6 +71,26 @@ class QuranView: UIView, UIGestureRecognizerDelegate, UINavigationBarDelegate { sendSubviewToBack(contentView) } + #if QURAN_SYNC + func showReadingBookmarkNudgeView(_ nudgeView: UIView) { + hideReadingBookmarkNudgeView() + readingBookmarkNudgeView = nudgeView + addAutoLayoutSubview(nudgeView) + + let horizontalInset = ContentDimension.interSpacing * 5 + NSLayoutConstraint.activate([ + nudgeView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalInset), + trailingAnchor.constraint(equalTo: nudgeView.trailingAnchor, constant: horizontalInset), + nudgeView.topAnchor.constraint(equalTo: navigationBar.bottomAnchor, constant: ContentDimension.interSpacing * 2), + ]) + } + + func hideReadingBookmarkNudgeView() { + readingBookmarkNudgeView?.removeFromSuperview() + readingBookmarkNudgeView = nil + } + #endif + func addAudioBannerView(_ audioBannerView: UIView) { audioView = audioBannerView addAutoLayoutSubview(audioBannerView) @@ -99,6 +120,9 @@ class QuranView: UIView, UIGestureRecognizerDelegate, UINavigationBarDelegate { private let tapGesture = UITapGestureRecognizer() private var audioView: UIView? + #if QURAN_SYNC + private var readingBookmarkNudgeView: UIView? + #endif private func setUp() { clipsToBounds = true diff --git a/Features/QuranViewFeature/QuranViewController.swift b/Features/QuranViewFeature/QuranViewController.swift index 51b98c404..3a0e04fca 100644 --- a/Features/QuranViewFeature/QuranViewController.swift +++ b/Features/QuranViewFeature/QuranViewController.swift @@ -114,6 +114,9 @@ class QuranViewController: BaseViewController, QuranViewDelegate, override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) UIApplication.shared.isIdleTimerDisabled = false + #if QURAN_SYNC + hideReadingBookmarkNudge() + #endif } // MARK: - Content Status @@ -248,11 +251,56 @@ class QuranViewController: BaseViewController, QuranViewDelegate, updateRightBarItems(animated: false, isBookmarked: isBookmarked) } + #if QURAN_SYNC + func showReadingBookmarkNudge(expanded: Bool, undo: @escaping () async -> Void) { + hideReadingBookmarkNudge() + let nudge = NoorEducationNudge( + titlePrefix: l("reading-bookmark.education.title.prefix"), + titleLink: l("reading-bookmark.education.title.link"), + message: l("reading-bookmark.education.body"), + actionTitle: l("reading-bookmark.education.undo"), + initiallyExpanded: expanded, + action: { [weak self] in + await undo() + self?.hideReadingBookmarkNudge() + } + ) + let viewController = UIHostingController(rootView: nudge) + viewController.view.backgroundColor = .clear + + readingBookmarkNudgeController = viewController + addChild(viewController) + quranView?.showReadingBookmarkNudgeView(viewController.view) + viewController.didMove(toParent: self) + + readingBookmarkNudgeHideTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 5_000_000_000) + await MainActor.run { + self?.hideReadingBookmarkNudge() + } + } + } + + func hideReadingBookmarkNudge() { + readingBookmarkNudgeHideTask?.cancel() + readingBookmarkNudgeHideTask = nil + if let readingBookmarkNudgeController { + removeChild(readingBookmarkNudgeController) + } + quranView?.hideReadingBookmarkNudgeView() + readingBookmarkNudgeController = nil + } + #endif + // MARK: Private private class TranslationsSelectionNavigationController: BaseNavigationController {} private var contentStatusView: UIHostingController? + #if QURAN_SYNC + private var readingBookmarkNudgeController: UIHostingController? + private var readingBookmarkNudgeHideTask: Task? + #endif private var cancellables: Set = [] private lazy var popoverPresenter = PhonePopoverPresenter(delegate: self) diff --git a/Features/QuranViewFeature/ReadingBookmarkPreferences.swift b/Features/QuranViewFeature/ReadingBookmarkPreferences.swift new file mode 100644 index 000000000..ec38fb683 --- /dev/null +++ b/Features/QuranViewFeature/ReadingBookmarkPreferences.swift @@ -0,0 +1,27 @@ +#if QURAN_SYNC + // + // ReadingBookmarkPreferences.swift + // + + import Preferences + + struct ReadingBookmarkPreferences { + // MARK: Lifecycle + + private init() {} + + // MARK: Internal + + static let shared = ReadingBookmarkPreferences() + + @Preference(educationShown) + var isEducationShown: Bool + + // MARK: Private + + private static let educationShown = PreferenceKey( + key: "com.quran.sync.reading-bookmark.education-shown", + defaultValue: false + ) + } +#endif diff --git a/Features/SettingsFeature/Tests/SettingsRootViewModelTests.swift b/Features/SettingsFeature/Tests/SettingsRootViewModelTests.swift index bcc7a87e4..99feb3d34 100644 --- a/Features/SettingsFeature/Tests/SettingsRootViewModelTests.swift +++ b/Features/SettingsFeature/Tests/SettingsRootViewModelTests.swift @@ -154,6 +154,9 @@ private struct AppDependenciesStub: AppDependencies { var lastPagePersistence: LastPagePersistence { fatalError("Unused in tests") } var notePersistence: NotePersistence { fatalError("Unused in tests") } var pageBookmarkPersistence: PageBookmarkPersistence { fatalError("Unused in tests") } + #if QURAN_SYNC + var syncService: SyncService? { nil } + #endif } private func makeUser(email: String?) -> UserInfo { diff --git a/Model/QuranAnnotations/HighlightColor.swift b/Model/QuranAnnotations/HighlightColor.swift new file mode 100644 index 000000000..0c396ad24 --- /dev/null +++ b/Model/QuranAnnotations/HighlightColor.swift @@ -0,0 +1,21 @@ +// +// HighlightColor.swift +// +// Created by Ahmed Nabil on 2026-05-06. +// + +public enum HighlightColor: String, CaseIterable, Equatable, Hashable { + case red + case green + case blue + case yellow + case purple + + public init?(collectionName: String) { + self.init(rawValue: collectionName) + } + + public var collectionName: String { + rawValue + } +} diff --git a/Model/QuranAnnotations/QuranHighlights.swift b/Model/QuranAnnotations/QuranHighlights.swift index 71d7832fe..63b017a90 100644 --- a/Model/QuranAnnotations/QuranHighlights.swift +++ b/Model/QuranAnnotations/QuranHighlights.swift @@ -17,6 +17,7 @@ public struct QuranHighlights: Equatable { public var readingVerses: [AyahNumber] = [] public var shareVerses: [AyahNumber] = [] public var searchVerses: [AyahNumber] = [] + public var highlightVerses: [AyahNumber: HighlightColor] = [:] public var noteVerses: [AyahNumber: Note] = [:] public var pointedWord: Word? diff --git a/Package.swift b/Package.swift index 87f779cd5..2cb89fb07 100644 --- a/Package.swift +++ b/Package.swift @@ -512,10 +512,13 @@ private func domainTargets() -> [[Target]] { "QuranTextKit", "Localization", "Analytics", + "ReadingService", + .product(name: "MobileSync", package: "mobile-sync-spm"), ], testDependencies: [ "LastPagePersistence", "PageBookmarkPersistence", "QuranKit", + .product(name: "MobileSync", package: "mobile-sync-spm"), ]), target(type, name: "SettingsService", hasTests: false, dependencies: [ @@ -561,6 +564,7 @@ private func featuresTargets() -> [[Target]] { "AppDependencies", "QuranAudioKit", "AnnotationsService", + "BookmarksFeature", "NoorUI", ]), @@ -725,6 +729,7 @@ private func featuresTargets() -> [[Target]] { "TranslationsFeature", "TranslationVerseFeature", "FeaturesSupport", + "BookmarksFeature", ]), target(type, name: "SettingsFeature", dependencies: [ diff --git a/UI/NoorUI/Components/HighlightPaletteIcon.swift b/UI/NoorUI/Components/HighlightPaletteIcon.swift new file mode 100644 index 000000000..e64d29356 --- /dev/null +++ b/UI/NoorUI/Components/HighlightPaletteIcon.swift @@ -0,0 +1,41 @@ +// +// HighlightPaletteIcon.swift +// +// Created by Ahmed Nabil on 2026-05-10. +// + +import QuranAnnotations +import SwiftUI + +public struct HighlightPaletteIcon: View { + // MARK: Lifecycle + + public init() {} + + // MARK: Public + + public var body: some View { + ZStack { + colorCircle(.purple) + .offset(x: purpleOffset) + colorCircle(.blue) + .offset(x: blueOffset) + colorCircle(.green) + } + .compositingGroup() + .shadow(color: Color.tertiarySystemGroupedBackground, radius: radius) + .padding(.trailing, trailingPadding) + } + + // MARK: Private + + @ScaledMetric private var trailingPadding = ContentDimension.interSpacing + @ScaledMetric private var purpleOffset = ContentDimension.interSpacing + @ScaledMetric private var blueOffset = ContentDimension.interSpacing / 2 + @ScaledMetric private var minLength = 20.0 + @ScaledMetric private var radius = 1.0 + + private func colorCircle(_ color: HighlightColor) -> some View { + ColoredCircle(color: color.color, selected: false, minLength: minLength) + } +} diff --git a/UI/NoorUI/Components/List/NoorListItem.swift b/UI/NoorUI/Components/List/NoorListItem.swift index 704e6a530..61882f278 100644 --- a/UI/NoorUI/Components/List/NoorListItem.swift +++ b/UI/NoorUI/Components/List/NoorListItem.swift @@ -30,14 +30,19 @@ public struct NoorListItem: View { // MARK: Lifecycle public init(_ image: NoorSystemImage, color: Color? = nil) { - self.image = image - self.color = color + content = AnyView( + image.image + .foregroundColor(color) + ) + } + + public init(@ViewBuilder content: () -> some View) { + self.content = AnyView(content()) } // MARK: Internal - let image: NoorSystemImage - let color: Color? + let content: AnyView } // MARK: Lifecycle @@ -149,12 +154,7 @@ public struct NoorListItem: View { } if let image { - if let color = image.color { - image.image.image - .foregroundColor(color) - } else { - image.image.image - } + image.content } VStack(alignment: .leading) { diff --git a/UI/NoorUI/Components/NoorEducationNudge.swift b/UI/NoorUI/Components/NoorEducationNudge.swift new file mode 100644 index 000000000..03cd6e695 --- /dev/null +++ b/UI/NoorUI/Components/NoorEducationNudge.swift @@ -0,0 +1,86 @@ +// +// NoorEducationNudge.swift +// +// Created by Ahmed Nabil on 2026-05-09. +// + +import SwiftUI +import UIx + +public struct NoorEducationNudge: View { + // MARK: Lifecycle + + public init( + titlePrefix: String, + titleLink: String, + message: String, + actionTitle: String, + initiallyExpanded: Bool, + action: @escaping () async -> Void + ) { + self.titlePrefix = titlePrefix + self.titleLink = titleLink + self.message = message + self.actionTitle = actionTitle + self.action = action + _isExpanded = State(initialValue: initiallyExpanded) + } + + // MARK: Public + + public var body: some View { + VStack(alignment: .leading, spacing: ContentDimension.interSpacing) { + HStack(spacing: ContentDimension.interSpacing) { + Button(action: { isExpanded.toggle() }) { + title + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .buttonStyle(.plain) + + Spacer(minLength: ContentDimension.interSpacing) + + AsyncButton(action: action) { + Text(actionTitle) + .font(.body.weight(.semibold)) + .foregroundColor(Color.appIdentity) + } + } + + if isExpanded { + HStack(alignment: .top, spacing: ContentDimension.interSpacing) { + Image(systemName: "questionmark.circle") + .foregroundColor(.secondary) + Text(message) + .font(.body) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.vertical, ContentDimension.interSpacing * 2) + .padding(.horizontal, ContentDimension.interSpacing * 3) + .background( + RoundedRectangle(cornerRadius: Dimensions.cornerRadius) + .fill(Color(.systemBackground)) + ) + } + + // MARK: Private + + private let titlePrefix: String + private let titleLink: String + private let message: String + private let actionTitle: String + private let action: () async -> Void + + @State private var isExpanded: Bool + + private var title: Text { + Text(titlePrefix) + .font(.body.weight(.semibold)) + + Text(titleLink) + .font(.body.weight(.semibold)) + .underline(!isExpanded) + } +} diff --git a/UI/NoorUI/Features/AyahMenu/AyahMenuUI.swift b/UI/NoorUI/Features/AyahMenu/AyahMenuUI.swift index 305922e90..47ba5eecd 100644 --- a/UI/NoorUI/Features/AyahMenu/AyahMenuUI.swift +++ b/UI/NoorUI/Features/AyahMenu/AyahMenuUI.swift @@ -15,7 +15,7 @@ public enum AyahMenuUI { public init( play: @escaping AsyncAction, repeatVerses: @escaping AsyncAction, - highlight: @Sendable @escaping (Note.Color) async -> Void, + highlight: @Sendable @escaping (HighlightColor) async -> Void, addNote: @escaping AsyncAction, deleteNote: @escaping AsyncAction, showTranslation: @escaping AsyncAction, @@ -36,7 +36,7 @@ public enum AyahMenuUI { let play: AsyncAction let repeatVerses: AsyncAction - let highlight: @Sendable (Note.Color) async -> Void + let highlight: @Sendable (HighlightColor) async -> Void let addNote: AsyncAction let deleteNote: AsyncAction let showTranslation: AsyncAction @@ -48,7 +48,7 @@ public enum AyahMenuUI { // MARK: Lifecycle public init( - highlightingColor: Note.Color, + highlightingColor: HighlightColor, state: NoteState, playSubtitle: String, repeatSubtitle: String, @@ -69,7 +69,7 @@ public enum AyahMenuUI { // MARK: Internal - let highlightingColor: Note.Color + let highlightingColor: HighlightColor let state: NoteState let actions: Actions let playSubtitle: String diff --git a/UI/NoorUI/Features/AyahMenu/AyahMenuView.swift b/UI/NoorUI/Features/AyahMenu/AyahMenuView.swift index 7f53845f5..5c5888a95 100644 --- a/UI/NoorUI/Features/AyahMenu/AyahMenuView.swift +++ b/UI/NoorUI/Features/AyahMenu/AyahMenuView.swift @@ -54,7 +54,7 @@ public struct AyahMenuView: View { @State private var state: MenuState = .list - private var existingHighlightedColor: Note.Color? { + private var existingHighlightedColor: HighlightColor? { switch dataObject.state { case .highlighted, .noted: return dataObject.highlightingColor @@ -322,29 +322,15 @@ private struct MenuGroup: View { } private struct IconCircles: View { - @ScaledMetric var trailingPadding = 8 - @ScaledMetric var purpleOffset = 8 - @ScaledMetric var blueOffset = 4 - @ScaledMetric var radius = 1 - var body: some View { - ZStack { - IconCircle(color: .purple) - .offset(x: purpleOffset) - IconCircle(color: .blue) - .offset(x: blueOffset) - IconCircle(color: .green) - } - .compositingGroup() - .shadow(color: Color.tertiarySystemGroupedBackground, radius: radius) - .padding(.trailing, trailingPadding) + HighlightPaletteIcon() } } private struct IconCircle: View { @ScaledMetric var minLength = 20 - var color: Note.Color + var color: HighlightColor var body: some View { ColoredCircle(color: color.color, selected: false, minLength: minLength) @@ -352,12 +338,12 @@ private struct IconCircle: View { } private struct NoteCircles: View { - let selectedColor: Note.Color? - let tapped: @Sendable (Note.Color) async -> Void + let selectedColor: HighlightColor? + let tapped: @Sendable (HighlightColor) async -> Void var body: some View { HStack { - ForEach(Note.Color.sortedColors, id: \.self) { color in + ForEach(HighlightColor.allCases, id: \.self) { color in AsyncButton( action: { await tapped(color) }, label: { NoteCircle(color: color.color, selected: color == selectedColor) } diff --git a/UI/NoorUI/Theme/HighlightColor+Theme.swift b/UI/NoorUI/Theme/HighlightColor+Theme.swift new file mode 100644 index 000000000..439da3792 --- /dev/null +++ b/UI/NoorUI/Theme/HighlightColor+Theme.swift @@ -0,0 +1,32 @@ +import Localization +import QuranAnnotations +import SwiftUI +import UIKit + +extension HighlightColor { + // MARK: Public + + public var uiColor: UIColor { + switch self { + case .red: return #colorLiteral(red: 0.9976003766, green: 0.6918323636, blue: 0.790571034, alpha: 1) + case .green: return #colorLiteral(red: 0.7582061887, green: 0.9266348481, blue: 0.441752553, alpha: 1) + case .blue: return #colorLiteral(red: 0.6776656508, green: 0.8418365121, blue: 0.994728744, alpha: 1) + case .yellow: return #colorLiteral(red: 0.9911049008, green: 0.9235726595, blue: 0.3886876702, alpha: 1) + case .purple: return #colorLiteral(red: 0.8482968211, green: 0.695538938, blue: 0.9965527654, alpha: 1) + } + } + + public var color: SwiftUI.Color { + SwiftUI.Color(uiColor) + } + + public var localizedName: String { + switch self { + case .red: return l("highlight.color.red") + case .green: return l("highlight.color.green") + case .blue: return l("highlight.color.blue") + case .yellow: return l("highlight.color.yellow") + case .purple: return l("highlight.color.purple") + } + } +} diff --git a/UI/NoorUI/Theme/QuranHighlights+Theme.swift b/UI/NoorUI/Theme/QuranHighlights+Theme.swift index 485da7d06..25eaaa3c3 100644 --- a/UI/NoorUI/Theme/QuranHighlights+Theme.swift +++ b/UI/NoorUI/Theme/QuranHighlights+Theme.swift @@ -38,6 +38,10 @@ extension QuranHighlights { // Sort order: share, reading, search, .note var versesByHighlights: [AyahNumber: UIColor] = [:] + for (verse, color) in highlightVerses { + versesByHighlights[verse] = color.uiColor.withAlphaComponent(Self.opacity) + } + for (verse, note) in noteVerses { versesByHighlights[verse] = note.color.uiColor.withAlphaComponent(Self.opacity) }