From 9d7663c5d5da54dc9f4178007d6da5064de34e03 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sat, 9 May 2026 00:53:30 +0300 Subject: [PATCH 01/21] Add synced highlights flow --- .../Resources/ar.lproj/Localizable.strings | 7 +- .../Resources/en.lproj/Localizable.strings | 7 +- .../AyahMenuFeature/AyahMenuBuilder.swift | 51 ++++-- .../AyahMenuFeature/AyahMenuViewModel.swift | 170 ++++++++++++++++-- .../Tests/HighlightCollectionsSyncTests.swift | 65 +++++++ .../HighlightCollections+Sync.swift | 39 ++++ .../ContentViewModel.swift | 4 +- Features/QuranViewFeature/QuranBuilder.swift | 8 +- .../QuranViewFeature/QuranInteractor.swift | 15 +- .../QuranSyncedHighlightsObserver.swift | 58 ++++++ Model/QuranAnnotations/HighlightColor.swift | 13 ++ Model/QuranAnnotations/QuranHighlights.swift | 1 + Package.swift | 5 + UI/NoorUI/Features/AyahMenu/AyahMenuUI.swift | 8 +- .../Features/AyahMenu/AyahMenuView.swift | 10 +- UI/NoorUI/Theme/HighlightColor++.swift | 32 ++++ UI/NoorUI/Theme/QuranHighlights+Theme.swift | 4 + 17 files changed, 458 insertions(+), 39 deletions(-) create mode 100644 Features/BookmarksFeature/Tests/HighlightCollectionsSyncTests.swift create mode 100644 Features/FeaturesSupport/HighlightCollections+Sync.swift create mode 100644 Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift create mode 100644 Model/QuranAnnotations/HighlightColor.swift create mode 100644 UI/NoorUI/Theme/HighlightColor++.swift diff --git a/Core/Localization/Resources/ar.lproj/Localizable.strings b/Core/Localization/Resources/ar.lproj/Localizable.strings index bba2e8157..eda4bcd6b 100644 --- a/Core/Localization/Resources/ar.lproj/Localizable.strings +++ b/Core/Localization/Resources/ar.lproj/Localizable.strings @@ -246,6 +246,11 @@ "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" = "بنفسجي"; diff --git a/Core/Localization/Resources/en.lproj/Localizable.strings b/Core/Localization/Resources/en.lproj/Localizable.strings index 9035e2cf5..8caa2a363 100644 --- a/Core/Localization/Resources/en.lproj/Localizable.strings +++ b/Core/Localization/Resources/en.lproj/Localizable.strings @@ -252,8 +252,13 @@ "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"; diff --git a/Features/AyahMenuFeature/AyahMenuBuilder.swift b/Features/AyahMenuFeature/AyahMenuBuilder.swift index 3da4c1f3a..0b57b429e 100644 --- a/Features/AyahMenuFeature/AyahMenuBuilder.swift +++ b/Features/AyahMenuFeature/AyahMenuBuilder.swift @@ -7,6 +7,9 @@ // import AppDependencies +#if QURAN_SYNC + import MobileSync +#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, + syncService: container.syncService + ) + #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..5da397b93 100644 --- a/Features/AyahMenuFeature/AyahMenuViewModel.swift +++ b/Features/AyahMenuFeature/AyahMenuViewModel.swift @@ -8,6 +8,10 @@ import AnnotationsService import Crashing +#if QURAN_SYNC + import FeaturesSupport + import MobileSync +#endif import Localization import NoorUI import QuranAnnotations @@ -25,14 +29,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 +47,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 syncService: SyncService? + #endif let quranContentStatePreferences = QuranContentStatePreferences.shared } @@ -65,7 +73,12 @@ final class AyahMenuViewModel { deps.quranContentStatePreferences.quranMode == .translation } - var highlightingColor: Note.Color { deps.noteService.color(from: deps.notes) } + var highlightingColor: HighlightColor { + 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 +118,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 +154,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.syncService != 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 +176,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 +222,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.syncService != 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 +263,106 @@ 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 { + let (targetCollection, collections) = try await highlightCollection(for: color) + 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 syncService.removeAyahBookmarkFromCollection(bookmark) + } + } + } + + for verse in deps.verses where !targetAyahs.contains(AyahKey(verse)) { + _ = try await syncService.addAyahBookmarkToCollection( + collectionLocalId: targetCollection.collection.localId, + sura: Int32(verse.sura.suraNumber), + ayah: Int32(verse.ayah) + ) + } + } + + private func removeSyncedHighlights() async throws { + let selectedAyahs = Set(deps.verses.map(AyahKey.init)) + for collection in try await collectionsSnapshot() where collection.highlightColor != nil { + for bookmark in collection.bookmarks where selectedAyahs.contains(AyahKey(bookmark)) { + try await syncService.removeAyahBookmarkFromCollection(bookmark) + } + } + } + + private func highlightCollection(for color: HighlightColor) async throws -> (CollectionWithAyahBookmarks, [CollectionWithAyahBookmarks]) { + let collections = try await collectionsSnapshot() + if let collection = collections.first(where: { $0.collection.name == color.collectionName }) { + return (collection, collections) + } + + try await syncService.createCollection(named: color.collectionName) + let updatedCollections = try await collectionsSnapshot() + guard let collection = updatedCollections.first(where: { $0.collection.name == color.collectionName }) else { + throw SyncedHighlightCollectionError.collectionUnavailable + } + return (collection, updatedCollections) + } + + private func collectionsSnapshot() async throws -> [CollectionWithAyahBookmarks] { + var iterator = syncService.collectionsWithBookmarksSequence().makeAsyncIterator() + return try await iterator.next() ?? [] + } + + private var syncService: SyncService { + guard let syncService = deps.syncService else { + fatalError("Expected sync service when QURAN_SYNC is enabled") + } + return syncService + } + #endif } + +#if QURAN_SYNC + private struct AyahKey: Hashable { + init(_ bookmark: CollectionAyahBookmark) { + sura = bookmark.sura + ayah = bookmark.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/Tests/HighlightCollectionsSyncTests.swift b/Features/BookmarksFeature/Tests/HighlightCollectionsSyncTests.swift new file mode 100644 index 000000000..f6e60d3b5 --- /dev/null +++ b/Features/BookmarksFeature/Tests/HighlightCollectionsSyncTests.swift @@ -0,0 +1,65 @@ +#if QURAN_SYNC + import FeaturesSupport + import MobileSync + import QuranAnnotations + import QuranKit + 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_highlightedAyahs_mapsOnlyHighlightCollections() { + let quran = Quran.hafsMadani1405 + let collections = [ + Self.collection(name: "red", bookmarks: [Self.bookmark(collectionLocalId: "red", sura: 1, ayah: 1)]), + Self.collection(name: "bookmarks", bookmarks: [Self.bookmark(collectionLocalId: "bookmarks", sura: 1, ayah: 2)]), + ] + + let highlights = collections.highlightedAyahs(quran: quran) + + XCTAssertEqual(highlights[AyahNumber(quran: quran, sura: 1, ayah: 1)!], .red) + XCTAssertNil(highlights[AyahNumber(quran: quran, sura: 1, ayah: 2)!]) + } + + private static func collection( + localId: String? = nil, + name: String, + bookmarks: [CollectionAyahBookmark] + ) -> CollectionWithAyahBookmarks { + CollectionWithAyahBookmarks( + collection: Collection_( + name: name, + lastUpdated: .distantPast, + localId: localId ?? name + ), + bookmarks: bookmarks + ) + } + + private static func bookmark( + collectionLocalId: String, + bookmarkLocalId: String? = nil, + sura: Int32, + ayah: Int32 + ) -> CollectionAyahBookmark { + CollectionAyahBookmark( + collectionLocalId: collectionLocalId, + collectionRemoteId: nil, + bookmarkLocalId: bookmarkLocalId ?? "\(collectionLocalId)-\(sura)-\(ayah)", + bookmarkRemoteId: nil, + sura: sura, + ayah: ayah, + lastUpdated: .distantPast, + localId: "\(collectionLocalId)-collection-\(sura)-\(ayah)" + ) + } + } +#endif diff --git a/Features/FeaturesSupport/HighlightCollections+Sync.swift b/Features/FeaturesSupport/HighlightCollections+Sync.swift new file mode 100644 index 000000000..ebefb839c --- /dev/null +++ b/Features/FeaturesSupport/HighlightCollections+Sync.swift @@ -0,0 +1,39 @@ +#if QURAN_SYNC + import MobileSync + import QuranAnnotations + import QuranKit + + public extension HighlightColor { + init?(collectionName: String) { + self.init(rawValue: collectionName) + } + + var collectionName: String { + rawValue + } + } + + public extension CollectionWithAyahBookmarks { + var highlightColor: HighlightColor? { + HighlightColor(collectionName: collection.name) + } + } + + public extension Sequence { + func highlightedAyahs(quran: Quran) -> [AyahNumber: HighlightColor] { + var highlights: [AyahNumber: HighlightColor] = [:] + for collection in self { + guard let color = collection.highlightColor else { + continue + } + for bookmark in collection.bookmarks { + guard let ayah = AyahNumber(quran: quran, sura: Int(bookmark.sura), ayah: Int(bookmark.ayah)) else { + continue + } + highlights[ayah] = color + } + } + return highlights + } + } +#endif diff --git a/Features/QuranContentFeature/ContentViewModel.swift b/Features/QuranContentFeature/ContentViewModel.swift index d3183bdc2..6ccc6c487 100644 --- a/Features/QuranContentFeature/ContentViewModel.swift +++ b/Features/QuranContentFeature/ContentViewModel.swift @@ -74,7 +74,9 @@ public final class ContentViewModel: ObservableObject { .sink { [weak self] in self?.quranMode = $0 } .store(in: &cancellables) - loadNotes() + #if !QURAN_SYNC + loadNotes() + #endif configureInitialPage() } diff --git a/Features/QuranViewFeature/QuranBuilder.swift b/Features/QuranViewFeature/QuranBuilder.swift index 84637028f..059d6500a 100644 --- a/Features/QuranViewFeature/QuranBuilder.swift +++ b/Features/QuranViewFeature/QuranBuilder.swift @@ -44,11 +44,15 @@ public struct QuranBuilder { analytics: container.analytics ) } + let syncedHighlightsObserver = container.syncService.map { + QuranSyncedHighlightsObserver(syncService: $0, highlightsService: highlightsService, quran: quran) + } 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 +63,8 @@ public struct QuranBuilder { translationVerseBuilder: TranslationVerseBuilder(container: container), resources: container.readingResources, syncedNoteService: syncedNoteService, - syncedNoteEditorBuilder: syncedNoteEditorBuilder + syncedNoteEditorBuilder: syncedNoteEditorBuilder, + syncedHighlightsObserver: syncedHighlightsObserver ) #else let interactorDeps = QuranInteractor.Deps( @@ -67,6 +72,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..4aba903bf 100644 --- a/Features/QuranViewFeature/QuranInteractor.swift +++ b/Features/QuranViewFeature/QuranInteractor.swift @@ -63,6 +63,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 +76,7 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis #if QURAN_SYNC let syncedNoteService: MobileSyncNoteService? let syncedNoteEditorBuilder: SyncedNoteEditorBuilder? + let syncedHighlightsObserver: QuranSyncedHighlightsObserver? #endif } @@ -110,6 +112,7 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis func start() { #if QURAN_SYNC + deps.syncedHighlightsObserver?.start() if let syncedNoteService = deps.syncedNoteService { startSyncedNotesObservation(syncedNoteService) } else { @@ -267,7 +270,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) @@ -443,6 +447,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 { diff --git a/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift b/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift new file mode 100644 index 000000000..0e408d6a7 --- /dev/null +++ b/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift @@ -0,0 +1,58 @@ +#if QURAN_SYNC + // + // QuranSyncedHighlightsObserver.swift + // + // Created by Ahmed Nabil on 2026-05-06. + // + + import AnnotationsService + import FeaturesSupport + import MobileSync + import QuranAnnotations + import QuranKit + import VLogging + + @MainActor + final class QuranSyncedHighlightsObserver { + // MARK: Lifecycle + + init(syncService: SyncService, highlightsService: QuranHighlightsService, quran: Quran) { + self.syncService = syncService + self.highlightsService = highlightsService + self.quran = quran + } + + deinit { + task?.cancel() + } + + // MARK: Internal + + func start() { + guard task == nil else { + return + } + let syncService = syncService + let highlightsService = highlightsService + let quran = quran + task = Task { + do { + for try await collections in syncService.collectionsWithBookmarksSequence() { + var highlights = highlightsService.highlights + highlights.highlightVerses = collections.highlightedAyahs(quran: quran) + highlightsService.highlights = highlights + } + } catch { + logger.error("Failed to observe synced highlights: \(error)") + } + } + } + + // MARK: Private + + private let syncService: SyncService + private let highlightsService: QuranHighlightsService + private let quran: Quran + private var task: Task? + } +#endif diff --git a/Model/QuranAnnotations/HighlightColor.swift b/Model/QuranAnnotations/HighlightColor.swift new file mode 100644 index 000000000..1eeb730b8 --- /dev/null +++ b/Model/QuranAnnotations/HighlightColor.swift @@ -0,0 +1,13 @@ +// +// 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 +} 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..ea78e78c1 100644 --- a/Package.swift +++ b/Package.swift @@ -549,6 +549,7 @@ private func featuresTargets() -> [[Target]] { "Analytics", "QuranAnnotations", "NoorUI", + .product(name: "MobileSync", package: "mobile-sync-spm"), ]), target(type, name: "ReciterListFeature", hasTests: false, dependencies: [ @@ -561,7 +562,9 @@ private func featuresTargets() -> [[Target]] { "AppDependencies", "QuranAudioKit", "AnnotationsService", + "FeaturesSupport", "NoorUI", + .product(name: "MobileSync", package: "mobile-sync-spm"), ]), target(type, name: "WhatsNewFeature", hasTests: false, dependencies: [ @@ -632,6 +635,7 @@ private func featuresTargets() -> [[Target]] { "Analytics", "AnnotationsService", "AuthenticationClient", + "FeaturesSupport", .product(name: "MobileSync", package: "mobile-sync-spm"), "PageBookmarkPersistence", ]), @@ -725,6 +729,7 @@ private func featuresTargets() -> [[Target]] { "TranslationsFeature", "TranslationVerseFeature", "FeaturesSupport", + .product(name: "MobileSync", package: "mobile-sync-spm"), ]), target(type, name: "SettingsFeature", dependencies: [ 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..92a359560 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 @@ -344,7 +344,7 @@ private struct IconCircles: View { 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 +352,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++.swift b/UI/NoorUI/Theme/HighlightColor++.swift new file mode 100644 index 000000000..439da3792 --- /dev/null +++ b/UI/NoorUI/Theme/HighlightColor++.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) } From fb9ddb6a2f569e0145be71156ac1524a55595979 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sat, 9 May 2026 00:55:29 +0300 Subject: [PATCH 02/21] Align highlight localizations with saved branch state --- .../Resources/ar.lproj/Localizable.strings | 18 ++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Core/Localization/Resources/ar.lproj/Localizable.strings b/Core/Localization/Resources/ar.lproj/Localizable.strings index eda4bcd6b..f1ed3c781 100644 --- a/Core/Localization/Resources/ar.lproj/Localizable.strings +++ b/Core/Localization/Resources/ar.lproj/Localizable.strings @@ -123,6 +123,16 @@ "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" = "أضف إشارات مرجعية للآيات لرؤيتها هنا."; + // MARK: - Notes "notes.no-data.title" = "إضافة آيات مرجعية..."; @@ -156,6 +166,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 diff --git a/Core/Localization/Resources/en.lproj/Localizable.strings b/Core/Localization/Resources/en.lproj/Localizable.strings index 8caa2a363..bd44abc29 100644 --- a/Core/Localization/Resources/en.lproj/Localizable.strings +++ b/Core/Localization/Resources/en.lproj/Localizable.strings @@ -127,6 +127,16 @@ "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."; + // MARK: - Notes "notes.no-data.title" = "Adding Ayah Highlights..."; @@ -160,6 +170,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 From b805ee35b754e6a14ca66cff643bab94844ed4c3 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sat, 9 May 2026 01:11:39 +0300 Subject: [PATCH 03/21] Localize bookmark collection names via HighlightColor --- .../AyahMenuFeature/AyahMenuBuilder.swift | 4 +- .../AyahMenuFeature/AyahMenuViewModel.swift | 69 ++++++++++--------- .../Sources/AyahBookmarkCollectionsView.swift | 10 ++- .../AyahBookmarkCollectionsViewModel.swift | 27 +++++++- ...yahBookmarkCollectionsViewModelTests.swift | 34 +++++++++ .../Tests/HighlightCollectionsSyncTests.swift | 53 ++------------ .../HighlightCollections+Sync.swift | 39 ----------- .../ContentViewModel.swift | 4 +- Features/QuranViewFeature/QuranBuilder.swift | 5 +- .../QuranSyncedHighlightsObserver.swift | 31 ++++++--- Model/QuranAnnotations/HighlightColor.swift | 8 +++ Package.swift | 7 +- 12 files changed, 146 insertions(+), 145 deletions(-) create mode 100644 Features/BookmarksFeature/Tests/AyahBookmarkCollectionsViewModelTests.swift delete mode 100644 Features/FeaturesSupport/HighlightCollections+Sync.swift diff --git a/Features/AyahMenuFeature/AyahMenuBuilder.swift b/Features/AyahMenuFeature/AyahMenuBuilder.swift index 0b57b429e..b4aeb7c22 100644 --- a/Features/AyahMenuFeature/AyahMenuBuilder.swift +++ b/Features/AyahMenuFeature/AyahMenuBuilder.swift @@ -8,7 +8,7 @@ import AppDependencies #if QURAN_SYNC - import MobileSync + import BookmarksFeature #endif import QuranAnnotations import QuranKit @@ -71,7 +71,7 @@ public struct AyahMenuBuilder { highlightColor: input.highlightColor, usesSyncedNotes: usesSyncedNotes, noteCount: input.noteCount, - syncService: container.syncService + ayahBookmarkCollectionService: container.syncService.map { AyahBookmarkCollectionService(syncService: $0) } ) #else let deps = AyahMenuViewModel.Deps( diff --git a/Features/AyahMenuFeature/AyahMenuViewModel.swift b/Features/AyahMenuFeature/AyahMenuViewModel.swift index 5da397b93..a8c67e727 100644 --- a/Features/AyahMenuFeature/AyahMenuViewModel.swift +++ b/Features/AyahMenuFeature/AyahMenuViewModel.swift @@ -9,8 +9,7 @@ import AnnotationsService import Crashing #if QURAN_SYNC - import FeaturesSupport - import MobileSync + import BookmarksFeature #endif import Localization import NoorUI @@ -54,7 +53,7 @@ final class AyahMenuViewModel { #if QURAN_SYNC let usesSyncedNotes: Bool let noteCount: Int - let syncService: SyncService? + let ayahBookmarkCollectionService: AyahBookmarkCollectionService? #endif let quranContentStatePreferences = QuranContentStatePreferences.shared } @@ -74,6 +73,11 @@ final class AyahMenuViewModel { } var highlightingColor: HighlightColor { + #if QURAN_SYNC + if deps.ayahBookmarkCollectionService != nil { + return deps.highlightColor ?? .yellow + } + #endif if let highlightColor = deps.highlightColor { return highlightColor } @@ -155,7 +159,7 @@ final class AyahMenuViewModel { logger.info("AyahMenu: delete notes. Verses: \(deps.verses)") listener?.dismissAyahMenu() #if QURAN_SYNC - if deps.highlightColor != nil, !containsText(deps.notes), deps.syncService != nil { + if deps.highlightColor != nil, !containsText(deps.notes), deps.ayahBookmarkCollectionService != nil { do { try await removeSyncedHighlights() } catch { @@ -230,7 +234,7 @@ final class AyahMenuViewModel { private func _updateHighlight(color: HighlightColor) async -> QuranAnnotations.Note? { #if QURAN_SYNC - if deps.syncService != nil { + if deps.ayahBookmarkCollectionService != nil { do { try await updateSyncedHighlight(color: color) return nil @@ -286,7 +290,10 @@ final class AyahMenuViewModel { #if QURAN_SYNC private func updateSyncedHighlight(color: HighlightColor) async throws { - let (targetCollection, collections) = try await highlightCollection(for: color) + 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() @@ -295,62 +302,62 @@ final class AyahMenuViewModel { if collection.collection.localId == targetCollection.collection.localId { targetAyahs.insert(AyahKey(bookmark)) } else { - try await syncService.removeAyahBookmarkFromCollection(bookmark) + try await ayahBookmarkCollectionService.removeBookmarkFromCollection(bookmark) } } } for verse in deps.verses where !targetAyahs.contains(AyahKey(verse)) { - _ = try await syncService.addAyahBookmarkToCollection( + try await ayahBookmarkCollectionService.addAyahBookmarkToCollection( collectionLocalId: targetCollection.collection.localId, - sura: Int32(verse.sura.suraNumber), - ayah: Int32(verse.ayah) + 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 collectionsSnapshot() where collection.highlightColor != nil { + for collection in try await ayahBookmarkCollectionService.collectionsSnapshot() where collection.highlightColor != nil { for bookmark in collection.bookmarks where selectedAyahs.contains(AyahKey(bookmark)) { - try await syncService.removeAyahBookmarkFromCollection(bookmark) + try await ayahBookmarkCollectionService.removeBookmarkFromCollection(bookmark) } } } - private func highlightCollection(for color: HighlightColor) async throws -> (CollectionWithAyahBookmarks, [CollectionWithAyahBookmarks]) { - let collections = try await collectionsSnapshot() + private func highlightCollection( + for color: HighlightColor, + using ayahBookmarkCollectionService: AyahBookmarkCollectionService + ) async throws -> (AyahBookmarkCollection, [AyahBookmarkCollection]) { + let collections = try await ayahBookmarkCollectionService.collectionsSnapshot() if let collection = collections.first(where: { $0.collection.name == color.collectionName }) { return (collection, collections) } - try await syncService.createCollection(named: color.collectionName) - let updatedCollections = try await collectionsSnapshot() + try await ayahBookmarkCollectionService.createCollection(named: color.collectionName) + let updatedCollections = try await ayahBookmarkCollectionService.collectionsSnapshot() guard let collection = updatedCollections.first(where: { $0.collection.name == color.collectionName }) else { throw SyncedHighlightCollectionError.collectionUnavailable } return (collection, updatedCollections) } - - private func collectionsSnapshot() async throws -> [CollectionWithAyahBookmarks] { - var iterator = syncService.collectionsWithBookmarksSequence().makeAsyncIterator() - return try await iterator.next() ?? [] - } - - private var syncService: SyncService { - guard let syncService = deps.syncService else { - fatalError("Expected sync service when QURAN_SYNC is enabled") - } - return syncService - } #endif } #if QURAN_SYNC + + private extension AyahBookmarkCollection { + var highlightColor: HighlightColor? { + HighlightColor(collectionName: collection.name) + } + } + private struct AyahKey: Hashable { - init(_ bookmark: CollectionAyahBookmark) { - sura = bookmark.sura - ayah = bookmark.ayah + init(_ bookmark: AyahCollectionBookmark) { + sura = Int32(bookmark.ayah.sura.suraNumber) + ayah = Int32(bookmark.ayah.ayah) } init(_ ayahNumber: AyahNumber) { diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift index b3c7ae676..85e44c194 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(rawValue: 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..6c864e3a2 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 @@ -33,13 +34,26 @@ @Published var collections: [AyahBookmarkCollection] = [] @Published var collapsedCollectionIDs: Set = [] + 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)) } } catch { self.error = error @@ -95,6 +109,13 @@ private let excludedCollectionNames: Set private let navigateToPage: (Page) -> Void + private 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 let name = collection.collection.name 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 index f6e60d3b5..022e788ae 100644 --- a/Features/BookmarksFeature/Tests/HighlightCollectionsSyncTests.swift +++ b/Features/BookmarksFeature/Tests/HighlightCollectionsSyncTests.swift @@ -1,8 +1,5 @@ #if QURAN_SYNC - import FeaturesSupport - import MobileSync import QuranAnnotations - import QuranKit import XCTest final class HighlightCollectionsSyncTests: XCTestCase { @@ -16,50 +13,12 @@ XCTAssertNil(HighlightColor(collectionName: "bookmarks")) } - func test_highlightedAyahs_mapsOnlyHighlightCollections() { - let quran = Quran.hafsMadani1405 - let collections = [ - Self.collection(name: "red", bookmarks: [Self.bookmark(collectionLocalId: "red", sura: 1, ayah: 1)]), - Self.collection(name: "bookmarks", bookmarks: [Self.bookmark(collectionLocalId: "bookmarks", sura: 1, ayah: 2)]), - ] - - let highlights = collections.highlightedAyahs(quran: quran) - - XCTAssertEqual(highlights[AyahNumber(quran: quran, sura: 1, ayah: 1)!], .red) - XCTAssertNil(highlights[AyahNumber(quran: quran, sura: 1, ayah: 2)!]) - } - - private static func collection( - localId: String? = nil, - name: String, - bookmarks: [CollectionAyahBookmark] - ) -> CollectionWithAyahBookmarks { - CollectionWithAyahBookmarks( - collection: Collection_( - name: name, - lastUpdated: .distantPast, - localId: localId ?? name - ), - bookmarks: bookmarks - ) - } - - private static func bookmark( - collectionLocalId: String, - bookmarkLocalId: String? = nil, - sura: Int32, - ayah: Int32 - ) -> CollectionAyahBookmark { - CollectionAyahBookmark( - collectionLocalId: collectionLocalId, - collectionRemoteId: nil, - bookmarkLocalId: bookmarkLocalId ?? "\(collectionLocalId)-\(sura)-\(ayah)", - bookmarkRemoteId: nil, - sura: sura, - ayah: ayah, - lastUpdated: .distantPast, - localId: "\(collectionLocalId)-collection-\(sura)-\(ayah)" - ) + 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/FeaturesSupport/HighlightCollections+Sync.swift b/Features/FeaturesSupport/HighlightCollections+Sync.swift deleted file mode 100644 index ebefb839c..000000000 --- a/Features/FeaturesSupport/HighlightCollections+Sync.swift +++ /dev/null @@ -1,39 +0,0 @@ -#if QURAN_SYNC - import MobileSync - import QuranAnnotations - import QuranKit - - public extension HighlightColor { - init?(collectionName: String) { - self.init(rawValue: collectionName) - } - - var collectionName: String { - rawValue - } - } - - public extension CollectionWithAyahBookmarks { - var highlightColor: HighlightColor? { - HighlightColor(collectionName: collection.name) - } - } - - public extension Sequence { - func highlightedAyahs(quran: Quran) -> [AyahNumber: HighlightColor] { - var highlights: [AyahNumber: HighlightColor] = [:] - for collection in self { - guard let color = collection.highlightColor else { - continue - } - for bookmark in collection.bookmarks { - guard let ayah = AyahNumber(quran: quran, sura: Int(bookmark.sura), ayah: Int(bookmark.ayah)) else { - continue - } - highlights[ayah] = color - } - } - return highlights - } - } -#endif diff --git a/Features/QuranContentFeature/ContentViewModel.swift b/Features/QuranContentFeature/ContentViewModel.swift index 6ccc6c487..d3183bdc2 100644 --- a/Features/QuranContentFeature/ContentViewModel.swift +++ b/Features/QuranContentFeature/ContentViewModel.swift @@ -74,9 +74,7 @@ public final class ContentViewModel: ObservableObject { .sink { [weak self] in self?.quranMode = $0 } .store(in: &cancellables) - #if !QURAN_SYNC - loadNotes() - #endif + loadNotes() configureInitialPage() } diff --git a/Features/QuranViewFeature/QuranBuilder.swift b/Features/QuranViewFeature/QuranBuilder.swift index 059d6500a..957d2cad3 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 @@ -45,7 +48,7 @@ public struct QuranBuilder { ) } let syncedHighlightsObserver = container.syncService.map { - QuranSyncedHighlightsObserver(syncService: $0, highlightsService: highlightsService, quran: quran) + QuranSyncedHighlightsObserver(ayahBookmarkCollectionService: AyahBookmarkCollectionService(syncService: $0), highlightsService: highlightsService) } let interactorDeps = QuranInteractor.Deps( quran: quran, diff --git a/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift b/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift index 0e408d6a7..e2c6b38ad 100644 --- a/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift +++ b/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift @@ -6,8 +6,7 @@ // import AnnotationsService - import FeaturesSupport - import MobileSync + import BookmarksFeature import QuranAnnotations import QuranKit import VLogging @@ -16,10 +15,9 @@ final class QuranSyncedHighlightsObserver { // MARK: Lifecycle - init(syncService: SyncService, highlightsService: QuranHighlightsService, quran: Quran) { - self.syncService = syncService + init(ayahBookmarkCollectionService: AyahBookmarkCollectionService, highlightsService: QuranHighlightsService) { + self.ayahBookmarkCollectionService = ayahBookmarkCollectionService self.highlightsService = highlightsService - self.quran = quran } deinit { @@ -32,14 +30,13 @@ guard task == nil else { return } - let syncService = syncService + let ayahBookmarkCollectionService = ayahBookmarkCollectionService let highlightsService = highlightsService - let quran = quran task = Task { do { - for try await collections in syncService.collectionsWithBookmarksSequence() { + try await ayahBookmarkCollectionService.observeCollections { collections in var highlights = highlightsService.highlights - highlights.highlightVerses = collections.highlightedAyahs(quran: quran) + highlights.highlightVerses = highlightedAyahs(from: collections) highlightsService.highlights = highlights } } catch { @@ -48,11 +45,23 @@ } } + 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 syncService: SyncService + private let ayahBookmarkCollectionService: AyahBookmarkCollectionService private let highlightsService: QuranHighlightsService - private let quran: Quran private var task: Task? } #endif diff --git a/Model/QuranAnnotations/HighlightColor.swift b/Model/QuranAnnotations/HighlightColor.swift index 1eeb730b8..0c396ad24 100644 --- a/Model/QuranAnnotations/HighlightColor.swift +++ b/Model/QuranAnnotations/HighlightColor.swift @@ -10,4 +10,12 @@ public enum HighlightColor: String, CaseIterable, Equatable, Hashable { case blue case yellow case purple + + public init?(collectionName: String) { + self.init(rawValue: collectionName) + } + + public var collectionName: String { + rawValue + } } diff --git a/Package.swift b/Package.swift index ea78e78c1..d3a97cfc1 100644 --- a/Package.swift +++ b/Package.swift @@ -549,7 +549,6 @@ private func featuresTargets() -> [[Target]] { "Analytics", "QuranAnnotations", "NoorUI", - .product(name: "MobileSync", package: "mobile-sync-spm"), ]), target(type, name: "ReciterListFeature", hasTests: false, dependencies: [ @@ -562,9 +561,8 @@ private func featuresTargets() -> [[Target]] { "AppDependencies", "QuranAudioKit", "AnnotationsService", - "FeaturesSupport", + "BookmarksFeature", "NoorUI", - .product(name: "MobileSync", package: "mobile-sync-spm"), ]), target(type, name: "WhatsNewFeature", hasTests: false, dependencies: [ @@ -635,7 +633,6 @@ private func featuresTargets() -> [[Target]] { "Analytics", "AnnotationsService", "AuthenticationClient", - "FeaturesSupport", .product(name: "MobileSync", package: "mobile-sync-spm"), "PageBookmarkPersistence", ]), @@ -729,7 +726,7 @@ private func featuresTargets() -> [[Target]] { "TranslationsFeature", "TranslationVerseFeature", "FeaturesSupport", - .product(name: "MobileSync", package: "mobile-sync-spm"), + "BookmarksFeature", ]), target(type, name: "SettingsFeature", dependencies: [ From 1093257afb19c49be504285cd3e5f82af2c7bab6 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sat, 9 May 2026 20:15:17 +0300 Subject: [PATCH 04/21] Consume collection updates as async sequence for highlights --- Features/AyahMenuFeature/AyahMenuViewModel.swift | 13 ++++++++++--- .../QuranSyncedHighlightsObserver.swift | 3 ++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Features/AyahMenuFeature/AyahMenuViewModel.swift b/Features/AyahMenuFeature/AyahMenuViewModel.swift index a8c67e727..de570db46 100644 --- a/Features/AyahMenuFeature/AyahMenuViewModel.swift +++ b/Features/AyahMenuFeature/AyahMenuViewModel.swift @@ -320,7 +320,7 @@ final class AyahMenuViewModel { return } let selectedAyahs = Set(deps.verses.map(AyahKey.init)) - for collection in try await ayahBookmarkCollectionService.collectionsSnapshot() where collection.highlightColor != nil { + 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) } @@ -331,13 +331,13 @@ final class AyahMenuViewModel { for color: HighlightColor, using ayahBookmarkCollectionService: AyahBookmarkCollectionService ) async throws -> (AyahBookmarkCollection, [AyahBookmarkCollection]) { - let collections = try await ayahBookmarkCollectionService.collectionsSnapshot() + 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.collectionsSnapshot() + let updatedCollections = try await ayahBookmarkCollectionService.collectionsFirstValue() guard let collection = updatedCollections.first(where: { $0.collection.name == color.collectionName }) else { throw SyncedHighlightCollectionError.collectionUnavailable } @@ -348,6 +348,13 @@ final class AyahMenuViewModel { #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) diff --git a/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift b/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift index e2c6b38ad..2246ee3ff 100644 --- a/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift +++ b/Features/QuranViewFeature/QuranSyncedHighlightsObserver.swift @@ -34,7 +34,8 @@ let highlightsService = highlightsService task = Task { do { - try await ayahBookmarkCollectionService.observeCollections { collections in + let sequence = ayahBookmarkCollectionService.collectionsSequence() + for try await collections in sequence { var highlights = highlightsService.highlights highlights.highlightVerses = highlightedAyahs(from: collections) highlightsService.highlights = highlights From 884f806e056c5aeaffef15c45838b8839f803572 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sun, 10 May 2026 02:15:19 +0300 Subject: [PATCH 05/21] Fix synced highlight collection entry points --- .../Resources/ar.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../AyahBookmarkCollectionsBuilder.swift | 24 +++++++++-- .../Sources/AyahBookmarkCollectionsView.swift | 2 +- .../AyahBookmarkCollectionsViewModel.swift | 10 +++++ .../Sources/BookmarksBuilder.swift | 25 +++++++++-- .../Sources/BookmarksView.swift | 22 ++++++++-- .../Sources/BookmarksViewModel.swift | 20 ++++++++- .../HighlightBookmarkCollections.swift | 20 +++++++++ .../Components/HighlightPaletteIcon.swift | 41 +++++++++++++++++++ UI/NoorUI/Components/List/NoorListItem.swift | 20 ++++----- .../Features/AyahMenu/AyahMenuView.swift | 16 +------- 12 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 Features/BookmarksFeature/Sources/HighlightBookmarkCollections.swift create mode 100644 UI/NoorUI/Components/HighlightPaletteIcon.swift diff --git a/Core/Localization/Resources/ar.lproj/Localizable.strings b/Core/Localization/Resources/ar.lproj/Localizable.strings index f1ed3c781..a7e6183a0 100644 --- a/Core/Localization/Resources/ar.lproj/Localizable.strings +++ b/Core/Localization/Resources/ar.lproj/Localizable.strings @@ -132,6 +132,7 @@ "bookmarks.collections.ayah-count" = "%d إشارات مرجعية للآيات"; "bookmarks.collections.ayahs.no-data.title" = "لا توجد إشارات مرجعية في هذه المجموعة"; "bookmarks.collections.ayahs.no-data.text" = "أضف إشارات مرجعية للآيات لرؤيتها هنا."; +"bookmarks.highlights" = "التمييزات"; // MARK: - Notes diff --git a/Core/Localization/Resources/en.lproj/Localizable.strings b/Core/Localization/Resources/en.lproj/Localizable.strings index bd44abc29..16369c95b 100644 --- a/Core/Localization/Resources/en.lproj/Localizable.strings +++ b/Core/Localization/Resources/en.lproj/Localizable.strings @@ -136,6 +136,7 @@ "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 diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift index 90db0fde5..36184f2e4 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift @@ -13,19 +13,33 @@ struct AyahBookmarkCollectionsBuilder { init( ayahBookmarkCollectionService: AyahBookmarkCollectionService, - navigateToPage: @escaping (Page) -> Void + includedCollectionNames: Set? = nil, + navigateToPage: @escaping (Page) -> Void, + title: String = l("bookmarks.collections"), + allowsCollectionManagement: Bool = true, + allowsBookmarkDeletion: Bool = true ) { self.ayahBookmarkCollectionService = ayahBookmarkCollectionService + self.includedCollectionNames = includedCollectionNames 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] : [], navigateToPage: navigateToPage ) - return AyahBookmarkCollectionsViewController(viewModel: viewModel) + return AyahBookmarkCollectionsViewController( + viewModel: viewModel, + title: title, + allowsCollectionManagement: allowsCollectionManagement, + allowsBookmarkDeletion: allowsBookmarkDeletion + ) } func buildOldPageBookmarks() -> UIViewController { @@ -45,6 +59,10 @@ private static let oldPageBookmarksCollectionName = "Old Page Bookmarks" private let ayahBookmarkCollectionService: AyahBookmarkCollectionService + private let includedCollectionNames: Set? 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 85e44c194..88772ad69 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift @@ -83,7 +83,7 @@ set: { viewModel.setCollection(collection, expanded: $0) } ) - let highlightColor = HighlightColor(rawValue: collection.collection.name) + let highlightColor = HighlightColor(collectionName: collection.collection.name) let allowsCollectionDeletion = allowsCollectionManagement && highlightColor == nil NoorEditableCollapsibleSection( diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift index 6c864e3a2..0ff0c516f 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift @@ -54,6 +54,7 @@ let sequence = ayahBookmarkCollectionService.collectionsSequence() for try await collections in sequence { self.collections = Self.sorted(filtered(collections)) + try await ensureHighlightCollections(collections) } } catch { self.error = error @@ -108,6 +109,7 @@ private let includedCollectionNames: Set? private let excludedCollectionNames: Set private let navigateToPage: (Page) -> Void + private var didEnsureHighlightCollections = false private static func highlightSortIndex(_ collection: AyahBookmarkCollection) -> Int? { guard let color = HighlightColor(collectionName: collection.collection.name) else { @@ -125,5 +127,13 @@ return !excludedCollectionNames.contains(name) } } + + private func ensureHighlightCollections(_ collections: [AyahBookmarkCollection]) async throws { + guard !didEnsureHighlightCollections, includedCollectionNames == nil else { + return + } + didEnsureHighlightCollections = true + try await HighlightBookmarkCollections.ensure(in: collections, using: ayahBookmarkCollectionService) + } } #endif diff --git a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift index 1093eb4e0..0eb277844 100644 --- a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift +++ b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift @@ -9,6 +9,7 @@ import AnnotationsService import AppDependencies import FeaturesSupport +import Localization import UIKit @MainActor @@ -24,16 +25,32 @@ public struct BookmarksBuilder { public func build(withListener listener: QuranNavigator) -> UIViewController { let service = PageBookmarkService(persistence: container.pageBookmarkPersistence) #if QURAN_SYNC + let showHighlightsAction: (@MainActor (UIViewController) async -> Void)? let showCollectionsAction: (@MainActor (UIViewController) async -> Void)? let showOldPageBookmarksAction: (@MainActor (UIViewController) async -> Void)? if let syncService = container.syncService { let ayahBookmarkCollectionService = AyahBookmarkCollectionService(syncService: syncService) + let navigateToPage: (Page) -> Void = { [weak listener] page in + listener?.navigateTo(page: page, lastPage: nil, highlightingSearchAyah: nil) + } + let highlightsBuilder = AyahBookmarkCollectionsBuilder( + ayahBookmarkCollectionService: ayahBookmarkCollectionService, + includedCollectionNames: Set(HighlightBookmarkCollections.names), + navigateToPage: navigateToPage, + title: l("bookmarks.highlights"), + allowsCollectionManagement: false + ) let collectionsBuilder = AyahBookmarkCollectionsBuilder( ayahBookmarkCollectionService: ayahBookmarkCollectionService, - navigateToPage: { [weak listener] page in - listener?.navigateTo(page: page, lastPage: nil, highlightingSearchAyah: nil) - } + navigateToPage: navigateToPage ) + showHighlightsAction = { presenter in + guard let navigationController = presenter.navigationController else { + return + } + let highlightsViewController = highlightsBuilder.build() + navigationController.pushViewController(highlightsViewController, animated: true) + } showCollectionsAction = { presenter in guard let navigationController = presenter.navigationController else { return @@ -49,6 +66,7 @@ public struct BookmarksBuilder { navigationController.pushViewController(oldPageBookmarksViewController, animated: true) } } else { + showHighlightsAction = nil showCollectionsAction = nil showOldPageBookmarksAction = nil } @@ -59,6 +77,7 @@ public struct BookmarksBuilder { navigateTo: { [weak listener] page in listener?.navigateTo(page: page, lastPage: nil, highlightingSearchAyah: nil) }, + showHighlightsAction: showHighlightsAction, showCollectionsAction: showCollectionsAction, showOldPageBookmarksAction: showOldPageBookmarksAction ) diff --git a/Features/BookmarksFeature/Sources/BookmarksView.swift b/Features/BookmarksFeature/Sources/BookmarksView.swift index 1ff391868..a16d24cbf 100644 --- a/Features/BookmarksFeature/Sources/BookmarksView.swift +++ b/Features/BookmarksFeature/Sources/BookmarksView.swift @@ -7,7 +7,6 @@ import Localization import NoorUI -import QuranAnnotations import QuranKit import SwiftUI import UIx @@ -31,6 +30,7 @@ struct BookmarksView: View { deleteAction: { await viewModel.deleteItem($0) }, dismissSyncBanner: { viewModel.dismissSyncBanner() }, signInAction: { await viewModel.loginToQuranCom() }, + showHighlightsAction: viewModel.showHighlightsAction, showCollectionsAction: viewModel.showCollectionsAction, showOldPageBookmarksAction: viewModel.showOldPageBookmarksAction ) @@ -52,6 +52,7 @@ private struct BookmarksViewUI: View { let deleteAction: AsyncItemAction let dismissSyncBanner: () -> Void let signInAction: @MainActor () async -> Void + let showHighlightsAction: AsyncAction? let showCollectionsAction: AsyncAction? let showOldPageBookmarksAction: AsyncAction? @@ -111,6 +112,15 @@ private struct BookmarksViewUI: View { ) } + private var highlightsRow: some View { + NoorListItem( + image: .init { HighlightPaletteIcon() }, + title: .text(l("bookmarks.highlights")), + accessory: .disclosureIndicator, + action: showHighlightsAction + ) + } + private var collectionsRow: some View { NoorListItem( image: .init(.folder, color: .accentColor), @@ -136,9 +146,14 @@ private struct BookmarksViewUI: View { } } - if showCollectionsAction != nil { + if showHighlightsAction != nil || showCollectionsAction != nil { NoorBasicSection { - collectionsRow + if showHighlightsAction != nil { + highlightsRow + } + if showCollectionsAction != nil { + collectionsRow + } } } #endif @@ -247,6 +262,7 @@ struct BookmarksView_Previews: PreviewProvider { deleteAction: { item in items = items.filter { $0 != item } }, dismissSyncBanner: {}, signInAction: {}, + showHighlightsAction: {}, showCollectionsAction: showCollectionsAction, showOldPageBookmarksAction: {} ) diff --git a/Features/BookmarksFeature/Sources/BookmarksViewModel.swift b/Features/BookmarksFeature/Sources/BookmarksViewModel.swift index f3d4a1909..535736e06 100644 --- a/Features/BookmarksFeature/Sources/BookmarksViewModel.swift +++ b/Features/BookmarksFeature/Sources/BookmarksViewModel.swift @@ -10,7 +10,6 @@ import AnnotationsService import AuthenticationClient import Combine import FeaturesSupport -import QuranAnnotations import QuranKit import ReadingService import SwiftUI @@ -26,6 +25,7 @@ final class BookmarksViewModel: ObservableObject { service: PageBookmarkService, authenticationClient: (any AuthenticationClient)?, navigateTo: @escaping (Page) -> Void, + showHighlightsAction: (@MainActor (UIViewController) async -> Void)? = nil, showCollectionsAction: (@MainActor (UIViewController) async -> Void)? = nil, showOldPageBookmarksAction: (@MainActor (UIViewController) async -> Void)? = nil ) { @@ -33,6 +33,7 @@ final class BookmarksViewModel: ObservableObject { self.service = service self.authenticationClient = authenticationClient self.navigateTo = navigateTo + presentHighlightsAction = showHighlightsAction presentCollectionsAction = showCollectionsAction presentOldPageBookmarksAction = showOldPageBookmarksAction isSyncBannerDismissed = preferences.isSyncBannerDismissed @@ -52,6 +53,15 @@ final class BookmarksViewModel: ObservableObject { !isAuthenticated && !isSyncBannerDismissed } + var showHighlightsAction: (@MainActor @Sendable () async -> Void)? { + guard presentHighlightsAction != nil else { + return nil + } + return { [weak self] in + await self?.showHighlights() + } + } + var showCollectionsAction: (@MainActor @Sendable () async -> Void)? { guard presentCollectionsAction != nil else { return nil @@ -136,6 +146,13 @@ final class BookmarksViewModel: ObservableObject { } } + func showHighlights() async { + guard let presenter else { + return + } + await presentHighlightsAction?(presenter) + } + func showCollections() async { guard let presenter else { return @@ -156,6 +173,7 @@ final class BookmarksViewModel: ObservableObject { private let analytics: AnalyticsLibrary private let service: PageBookmarkService private let authenticationClient: (any AuthenticationClient)? + private let presentHighlightsAction: (@MainActor (UIViewController) async -> Void)? private let presentCollectionsAction: (@MainActor (UIViewController) async -> Void)? private let presentOldPageBookmarksAction: (@MainActor (UIViewController) async -> Void)? private let readingPreferences = ReadingPreferences.shared 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/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/Features/AyahMenu/AyahMenuView.swift b/UI/NoorUI/Features/AyahMenu/AyahMenuView.swift index 92a359560..5c5888a95 100644 --- a/UI/NoorUI/Features/AyahMenu/AyahMenuView.swift +++ b/UI/NoorUI/Features/AyahMenu/AyahMenuView.swift @@ -322,22 +322,8 @@ 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() } } From dc404a2faf926b4ba1dba7f36b353af80c50b8d8 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sun, 10 May 2026 02:29:16 +0300 Subject: [PATCH 06/21] Ensure filtered highlight collections --- .../Sources/AyahBookmarkCollectionsViewModel.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift index 0ff0c516f..88dc43169 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift @@ -111,6 +111,10 @@ private let navigateToPage: (Page) -> Void private var didEnsureHighlightCollections = false + private var shouldEnsureHighlightCollections: Bool { + includedCollectionNames == nil || includedCollectionNames == Set(HighlightBookmarkCollections.names) + } + private static func highlightSortIndex(_ collection: AyahBookmarkCollection) -> Int? { guard let color = HighlightColor(collectionName: collection.collection.name) else { return nil @@ -129,7 +133,7 @@ } private func ensureHighlightCollections(_ collections: [AyahBookmarkCollection]) async throws { - guard !didEnsureHighlightCollections, includedCollectionNames == nil else { + guard !didEnsureHighlightCollections, shouldEnsureHighlightCollections else { return } didEnsureHighlightCollections = true From 1aabac4206ee457cc4ab0b102ce77c2d053b9bfc Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Thu, 14 May 2026 09:44:14 +0300 Subject: [PATCH 07/21] Restore bookmark model imports --- Features/BookmarksFeature/Sources/BookmarksView.swift | 1 + Features/BookmarksFeature/Sources/BookmarksViewModel.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Features/BookmarksFeature/Sources/BookmarksView.swift b/Features/BookmarksFeature/Sources/BookmarksView.swift index a16d24cbf..24d4c1ff0 100644 --- a/Features/BookmarksFeature/Sources/BookmarksView.swift +++ b/Features/BookmarksFeature/Sources/BookmarksView.swift @@ -7,6 +7,7 @@ import Localization import NoorUI +import QuranAnnotations import QuranKit import SwiftUI import UIx diff --git a/Features/BookmarksFeature/Sources/BookmarksViewModel.swift b/Features/BookmarksFeature/Sources/BookmarksViewModel.swift index 535736e06..e7191dd9f 100644 --- a/Features/BookmarksFeature/Sources/BookmarksViewModel.swift +++ b/Features/BookmarksFeature/Sources/BookmarksViewModel.swift @@ -10,6 +10,7 @@ import AnnotationsService import AuthenticationClient import Combine import FeaturesSupport +import QuranAnnotations import QuranKit import ReadingService import SwiftUI From 4973e9e7fbdf81efe9fa5b817090f609e4a0e3d5 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Thu, 14 May 2026 09:54:46 +0300 Subject: [PATCH 08/21] Import QuranKit for bookmark navigation --- Features/BookmarksFeature/Sources/BookmarksBuilder.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift index 0eb277844..331026de7 100644 --- a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift +++ b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift @@ -10,6 +10,7 @@ import AnnotationsService import AppDependencies import FeaturesSupport import Localization +import QuranKit import UIKit @MainActor From 53c1e3efee3735170df9244dc437042da6b6d853 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sun, 10 May 2026 22:43:36 +0300 Subject: [PATCH 09/21] Decouple highlight collection preparation --- .../AyahBookmarkCollectionsBuilder.swift | 4 ++++ .../AyahBookmarkCollectionsViewModel.swift | 19 +++++++++---------- .../Sources/BookmarksBuilder.swift | 5 +++++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift index 36184f2e4..b514712f5 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsBuilder.swift @@ -14,6 +14,7 @@ init( ayahBookmarkCollectionService: AyahBookmarkCollectionService, includedCollectionNames: Set? = nil, + prepareCollections: @escaping ([AyahBookmarkCollection]) async throws -> Void = { _ in }, navigateToPage: @escaping (Page) -> Void, title: String = l("bookmarks.collections"), allowsCollectionManagement: Bool = true, @@ -21,6 +22,7 @@ ) { self.ayahBookmarkCollectionService = ayahBookmarkCollectionService self.includedCollectionNames = includedCollectionNames + self.prepareCollections = prepareCollections self.navigateToPage = navigateToPage self.title = title self.allowsCollectionManagement = allowsCollectionManagement @@ -32,6 +34,7 @@ ayahBookmarkCollectionService: ayahBookmarkCollectionService, includedCollectionNames: includedCollectionNames, excludedCollectionNames: includedCollectionNames == nil ? [Self.oldPageBookmarksCollectionName] : [], + prepareCollections: prepareCollections, navigateToPage: navigateToPage ) return AyahBookmarkCollectionsViewController( @@ -60,6 +63,7 @@ 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 diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift index 88dc43169..9c427ce02 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift @@ -19,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 } @@ -54,7 +56,7 @@ let sequence = ayahBookmarkCollectionService.collectionsSequence() for try await collections in sequence { self.collections = Self.sorted(filtered(collections)) - try await ensureHighlightCollections(collections) + try await prepareCollectionsIfNeeded(collections) } } catch { self.error = error @@ -108,12 +110,9 @@ 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 didEnsureHighlightCollections = false - - private var shouldEnsureHighlightCollections: Bool { - includedCollectionNames == nil || includedCollectionNames == Set(HighlightBookmarkCollections.names) - } + private var didPrepareCollections = false private static func highlightSortIndex(_ collection: AyahBookmarkCollection) -> Int? { guard let color = HighlightColor(collectionName: collection.collection.name) else { @@ -132,12 +131,12 @@ } } - private func ensureHighlightCollections(_ collections: [AyahBookmarkCollection]) async throws { - guard !didEnsureHighlightCollections, shouldEnsureHighlightCollections else { + private func prepareCollectionsIfNeeded(_ collections: [AyahBookmarkCollection]) async throws { + guard !didPrepareCollections else { return } - didEnsureHighlightCollections = true - try await HighlightBookmarkCollections.ensure(in: collections, using: ayahBookmarkCollectionService) + didPrepareCollections = true + try await prepareCollections(collections) } } #endif diff --git a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift index 331026de7..7a9cfd0a2 100644 --- a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift +++ b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift @@ -31,18 +31,23 @@ public struct BookmarksBuilder { let showOldPageBookmarksAction: (@MainActor (UIViewController) async -> Void)? if let syncService = container.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 highlightsBuilder = AyahBookmarkCollectionsBuilder( ayahBookmarkCollectionService: ayahBookmarkCollectionService, includedCollectionNames: Set(HighlightBookmarkCollections.names), + prepareCollections: prepareHighlightCollections, navigateToPage: navigateToPage, title: l("bookmarks.highlights"), allowsCollectionManagement: false ) let collectionsBuilder = AyahBookmarkCollectionsBuilder( ayahBookmarkCollectionService: ayahBookmarkCollectionService, + prepareCollections: prepareHighlightCollections, navigateToPage: navigateToPage ) showHighlightsAction = { presenter in From 1f4db83e006faac147185a979d548896c784429d Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Mon, 11 May 2026 01:17:39 +0300 Subject: [PATCH 10/21] Hide standalone highlights bookmark row --- .../Sources/BookmarksBuilder.swift | 18 ---------------- .../Sources/BookmarksView.swift | 21 ++----------------- .../Sources/BookmarksViewModel.swift | 19 ----------------- 3 files changed, 2 insertions(+), 56 deletions(-) diff --git a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift index 7a9cfd0a2..3c98c25d2 100644 --- a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift +++ b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift @@ -26,7 +26,6 @@ public struct BookmarksBuilder { public func build(withListener listener: QuranNavigator) -> UIViewController { let service = PageBookmarkService(persistence: container.pageBookmarkPersistence) #if QURAN_SYNC - let showHighlightsAction: (@MainActor (UIViewController) async -> Void)? let showCollectionsAction: (@MainActor (UIViewController) async -> Void)? let showOldPageBookmarksAction: (@MainActor (UIViewController) async -> Void)? if let syncService = container.syncService { @@ -37,26 +36,11 @@ public struct BookmarksBuilder { let navigateToPage: (Page) -> Void = { [weak listener] page in listener?.navigateTo(page: page, lastPage: nil, highlightingSearchAyah: nil) } - let highlightsBuilder = AyahBookmarkCollectionsBuilder( - ayahBookmarkCollectionService: ayahBookmarkCollectionService, - includedCollectionNames: Set(HighlightBookmarkCollections.names), - prepareCollections: prepareHighlightCollections, - navigateToPage: navigateToPage, - title: l("bookmarks.highlights"), - allowsCollectionManagement: false - ) let collectionsBuilder = AyahBookmarkCollectionsBuilder( ayahBookmarkCollectionService: ayahBookmarkCollectionService, prepareCollections: prepareHighlightCollections, navigateToPage: navigateToPage ) - showHighlightsAction = { presenter in - guard let navigationController = presenter.navigationController else { - return - } - let highlightsViewController = highlightsBuilder.build() - navigationController.pushViewController(highlightsViewController, animated: true) - } showCollectionsAction = { presenter in guard let navigationController = presenter.navigationController else { return @@ -72,7 +56,6 @@ public struct BookmarksBuilder { navigationController.pushViewController(oldPageBookmarksViewController, animated: true) } } else { - showHighlightsAction = nil showCollectionsAction = nil showOldPageBookmarksAction = nil } @@ -83,7 +66,6 @@ public struct BookmarksBuilder { navigateTo: { [weak listener] page in listener?.navigateTo(page: page, lastPage: nil, highlightingSearchAyah: nil) }, - showHighlightsAction: showHighlightsAction, showCollectionsAction: showCollectionsAction, showOldPageBookmarksAction: showOldPageBookmarksAction ) diff --git a/Features/BookmarksFeature/Sources/BookmarksView.swift b/Features/BookmarksFeature/Sources/BookmarksView.swift index 24d4c1ff0..1ff391868 100644 --- a/Features/BookmarksFeature/Sources/BookmarksView.swift +++ b/Features/BookmarksFeature/Sources/BookmarksView.swift @@ -31,7 +31,6 @@ struct BookmarksView: View { deleteAction: { await viewModel.deleteItem($0) }, dismissSyncBanner: { viewModel.dismissSyncBanner() }, signInAction: { await viewModel.loginToQuranCom() }, - showHighlightsAction: viewModel.showHighlightsAction, showCollectionsAction: viewModel.showCollectionsAction, showOldPageBookmarksAction: viewModel.showOldPageBookmarksAction ) @@ -53,7 +52,6 @@ private struct BookmarksViewUI: View { let deleteAction: AsyncItemAction let dismissSyncBanner: () -> Void let signInAction: @MainActor () async -> Void - let showHighlightsAction: AsyncAction? let showCollectionsAction: AsyncAction? let showOldPageBookmarksAction: AsyncAction? @@ -113,15 +111,6 @@ private struct BookmarksViewUI: View { ) } - private var highlightsRow: some View { - NoorListItem( - image: .init { HighlightPaletteIcon() }, - title: .text(l("bookmarks.highlights")), - accessory: .disclosureIndicator, - action: showHighlightsAction - ) - } - private var collectionsRow: some View { NoorListItem( image: .init(.folder, color: .accentColor), @@ -147,14 +136,9 @@ private struct BookmarksViewUI: View { } } - if showHighlightsAction != nil || showCollectionsAction != nil { + if showCollectionsAction != nil { NoorBasicSection { - if showHighlightsAction != nil { - highlightsRow - } - if showCollectionsAction != nil { - collectionsRow - } + collectionsRow } } #endif @@ -263,7 +247,6 @@ struct BookmarksView_Previews: PreviewProvider { deleteAction: { item in items = items.filter { $0 != item } }, dismissSyncBanner: {}, signInAction: {}, - showHighlightsAction: {}, showCollectionsAction: showCollectionsAction, showOldPageBookmarksAction: {} ) diff --git a/Features/BookmarksFeature/Sources/BookmarksViewModel.swift b/Features/BookmarksFeature/Sources/BookmarksViewModel.swift index e7191dd9f..f3d4a1909 100644 --- a/Features/BookmarksFeature/Sources/BookmarksViewModel.swift +++ b/Features/BookmarksFeature/Sources/BookmarksViewModel.swift @@ -26,7 +26,6 @@ final class BookmarksViewModel: ObservableObject { service: PageBookmarkService, authenticationClient: (any AuthenticationClient)?, navigateTo: @escaping (Page) -> Void, - showHighlightsAction: (@MainActor (UIViewController) async -> Void)? = nil, showCollectionsAction: (@MainActor (UIViewController) async -> Void)? = nil, showOldPageBookmarksAction: (@MainActor (UIViewController) async -> Void)? = nil ) { @@ -34,7 +33,6 @@ final class BookmarksViewModel: ObservableObject { self.service = service self.authenticationClient = authenticationClient self.navigateTo = navigateTo - presentHighlightsAction = showHighlightsAction presentCollectionsAction = showCollectionsAction presentOldPageBookmarksAction = showOldPageBookmarksAction isSyncBannerDismissed = preferences.isSyncBannerDismissed @@ -54,15 +52,6 @@ final class BookmarksViewModel: ObservableObject { !isAuthenticated && !isSyncBannerDismissed } - var showHighlightsAction: (@MainActor @Sendable () async -> Void)? { - guard presentHighlightsAction != nil else { - return nil - } - return { [weak self] in - await self?.showHighlights() - } - } - var showCollectionsAction: (@MainActor @Sendable () async -> Void)? { guard presentCollectionsAction != nil else { return nil @@ -147,13 +136,6 @@ final class BookmarksViewModel: ObservableObject { } } - func showHighlights() async { - guard let presenter else { - return - } - await presentHighlightsAction?(presenter) - } - func showCollections() async { guard let presenter else { return @@ -174,7 +156,6 @@ final class BookmarksViewModel: ObservableObject { private let analytics: AnalyticsLibrary private let service: PageBookmarkService private let authenticationClient: (any AuthenticationClient)? - private let presentHighlightsAction: (@MainActor (UIViewController) async -> Void)? private let presentCollectionsAction: (@MainActor (UIViewController) async -> Void)? private let presentOldPageBookmarksAction: (@MainActor (UIViewController) async -> Void)? private let readingPreferences = ReadingPreferences.shared From f61d96e94cbf795684b75dad89f3c62a745d7211 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sun, 17 May 2026 01:33:40 +0300 Subject: [PATCH 11/21] Rename highlight color theme extension --- .../Theme/{HighlightColor++.swift => HighlightColor+Theme.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename UI/NoorUI/Theme/{HighlightColor++.swift => HighlightColor+Theme.swift} (100%) diff --git a/UI/NoorUI/Theme/HighlightColor++.swift b/UI/NoorUI/Theme/HighlightColor+Theme.swift similarity index 100% rename from UI/NoorUI/Theme/HighlightColor++.swift rename to UI/NoorUI/Theme/HighlightColor+Theme.swift From 28f3f2d45dd557ab0324bf7a84c456de698e045f Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sat, 9 May 2026 23:38:48 +0300 Subject: [PATCH 12/21] Add sync reading bookmark flow --- .../Resources/ar.lproj/Localizable.strings | 6 + .../Resources/en.lproj/Localizable.strings | 6 + .../AyahBookmarkCollectionsViewModel.swift | 4 +- .../Sources/ReadingBookmarkPreferences.swift | 29 +++++ .../Sources/ReadingBookmarkService.swift | 110 +++++++++++++++++ .../Tests/ReadingBookmarkServiceTests.swift | 46 +++++++ Features/QuranViewFeature/QuranBuilder.swift | 6 +- .../QuranViewFeature/QuranInteractor.swift | 112 +++++++++++++++++- Features/QuranViewFeature/QuranView.swift | 24 ++++ .../QuranViewController.swift | 48 ++++++++ .../Tests/SettingsRootViewModelTests.swift | 3 + Package.swift | 1 + UI/NoorUI/Components/NoorEducationNudge.swift | 85 +++++++++++++ 13 files changed, 473 insertions(+), 7 deletions(-) create mode 100644 Features/BookmarksFeature/Sources/ReadingBookmarkPreferences.swift create mode 100644 Features/BookmarksFeature/Sources/ReadingBookmarkService.swift create mode 100644 Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift create mode 100644 UI/NoorUI/Components/NoorEducationNudge.swift diff --git a/Core/Localization/Resources/ar.lproj/Localizable.strings b/Core/Localization/Resources/ar.lproj/Localizable.strings index a7e6183a0..95971d6de 100644 --- a/Core/Localization/Resources/ar.lproj/Localizable.strings +++ b/Core/Localization/Resources/ar.lproj/Localizable.strings @@ -273,3 +273,9 @@ "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" = "تراجع"; diff --git a/Core/Localization/Resources/en.lproj/Localizable.strings b/Core/Localization/Resources/en.lproj/Localizable.strings index 16369c95b..6bf290e2b 100644 --- a/Core/Localization/Resources/en.lproj/Localizable.strings +++ b/Core/Localization/Resources/en.lproj/Localizable.strings @@ -281,3 +281,9 @@ "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"; diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift index 9c427ce02..2cc7a00d8 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift @@ -36,7 +36,7 @@ @Published var collections: [AyahBookmarkCollection] = [] @Published var collapsedCollectionIDs: Set = [] - static func sorted(_ collections: [AyahBookmarkCollection]) -> [AyahBookmarkCollection] { + nonisolated static func sorted(_ collections: [AyahBookmarkCollection]) -> [AyahBookmarkCollection] { collections.sorted { lhs, rhs in switch (highlightSortIndex(lhs), highlightSortIndex(rhs)) { case let (lhsIndex?, rhsIndex?): @@ -114,7 +114,7 @@ private let navigateToPage: (Page) -> Void private var didPrepareCollections = false - private static func highlightSortIndex(_ collection: AyahBookmarkCollection) -> Int? { + private nonisolated static func highlightSortIndex(_ collection: AyahBookmarkCollection) -> Int? { guard let color = HighlightColor(collectionName: collection.collection.name) else { return nil } diff --git a/Features/BookmarksFeature/Sources/ReadingBookmarkPreferences.swift b/Features/BookmarksFeature/Sources/ReadingBookmarkPreferences.swift new file mode 100644 index 000000000..91e818fb4 --- /dev/null +++ b/Features/BookmarksFeature/Sources/ReadingBookmarkPreferences.swift @@ -0,0 +1,29 @@ +#if QURAN_SYNC + // + // ReadingBookmarkPreferences.swift + // + // Created by OpenAI on 2026-05-09. + // + + 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/BookmarksFeature/Sources/ReadingBookmarkService.swift b/Features/BookmarksFeature/Sources/ReadingBookmarkService.swift new file mode 100644 index 000000000..985a49ad4 --- /dev/null +++ b/Features/BookmarksFeature/Sources/ReadingBookmarkService.swift @@ -0,0 +1,110 @@ +#if QURAN_SYNC + // + // ReadingBookmarkService.swift + // + // Created by OpenAI on 2026-05-09. + // + + import MobileSync + import QuranKit + import ReadingService + + public enum QuranReadingBookmark: Equatable { + case ayah(AyahNumber) + case page(Page) + + public var page: Page { + switch self { + case .ayah(let ayah): ayah.page + case .page(let page): page + } + } + } + + 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 nextEducationPresentationIsExpanded() -> Bool { + let isExpanded = !ReadingBookmarkPreferences.shared.isEducationShown + ReadingBookmarkPreferences.shared.isEducationShown = true + return isExpanded + } + + public func readingBookmarkSequence() -> ReadingBookmarkSequence { + let sequence = syncService.readingBookmarkSequence() + .map { bookmark in + Self.bookmark(from: bookmark, quran: readingPreferences.reading.quran) + } + return ReadingBookmarkSequence(sequence) + } + + public func addReadingBookmark(page: Page) async throws { + _ = try await syncService.addPageReadingBookmark(page: Int32(page.pageNumber)) + } + + public func addReadingBookmark(ayah: AyahNumber) async throws { + _ = try await syncService.addAyahReadingBookmark( + sura: Int32(ayah.sura.suraNumber), + ayah: Int32(ayah.ayah) + ) + } + + 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) + case let bookmark as PageReadingBookmark: + return Page(quran: quran, pageNumber: Int(bookmark.page)).map(QuranReadingBookmark.page) + default: + return nil + } + } + + // MARK: Private + + private let syncService: SyncService + private let readingPreferences: ReadingPreferences + } +#endif diff --git a/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift b/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift new file mode 100644 index 000000000..14854669a --- /dev/null +++ b/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift @@ -0,0 +1,46 @@ +#if QURAN_SYNC + import MobileSync + import QuranKit + import XCTest + @testable import BookmarksFeature + + 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)!) + ) + } + + 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)!) + ) + } + + 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/QuranViewFeature/QuranBuilder.swift b/Features/QuranViewFeature/QuranBuilder.swift index 957d2cad3..21f965a3d 100644 --- a/Features/QuranViewFeature/QuranBuilder.swift +++ b/Features/QuranViewFeature/QuranBuilder.swift @@ -50,6 +50,9 @@ public struct QuranBuilder { 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, @@ -67,7 +70,8 @@ public struct QuranBuilder { resources: container.readingResources, syncedNoteService: syncedNoteService, syncedNoteEditorBuilder: syncedNoteEditorBuilder, - syncedHighlightsObserver: syncedHighlightsObserver + syncedHighlightsObserver: syncedHighlightsObserver, + readingBookmarkService: readingBookmarkService ) #else let interactorDeps = QuranInteractor.Deps( diff --git a/Features/QuranViewFeature/QuranInteractor.swift b/Features/QuranViewFeature/QuranInteractor.swift index 4aba903bf..2b3187a2a 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 @@ -77,6 +82,7 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis let syncedNoteService: MobileSyncNoteService? let syncedNoteEditorBuilder: SyncedNoteEditorBuilder? let syncedHighlightsObserver: QuranSyncedHighlightsObserver? + let readingBookmarkService: ReadingBookmarkService? #endif } @@ -91,6 +97,7 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis deinit { #if QURAN_SYNC syncedNotesObservationTask?.cancel() + readingBookmarkTask?.cancel() #endif } @@ -122,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() } @@ -315,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) @@ -350,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 @@ -376,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) @@ -401,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]) { @@ -493,6 +541,47 @@ 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) + try await service.addReadingBookmark(page: page) + readingBookmark = .page(page) + showReadingBookmarkNudge(using: service) + } + } catch { + crasher.recordError(error, reason: "Failed to toggle reading bookmark") + } + } + + private func showReadingBookmarkNudge(using service: ReadingBookmarkService) { + presenter?.showReadingBookmarkNudge(expanded: service.nextEducationPresentationIsExpanded()) { [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() { @@ -503,10 +592,25 @@ 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 { + guard case .page(let page) = readingBookmark else { + return false + } + return pages.contains(page) + } + #endif + private func showPageBookmarkIfNeeded(for pages: [Page]) { presenter?.updateBookmark(bookmarked(pages)) } 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/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/Package.swift b/Package.swift index d3a97cfc1..bbdf014b6 100644 --- a/Package.swift +++ b/Package.swift @@ -512,6 +512,7 @@ private func domainTargets() -> [[Target]] { "QuranTextKit", "Localization", "Analytics", + .product(name: "MobileSync", package: "mobile-sync-spm"), ], testDependencies: [ "LastPagePersistence", "PageBookmarkPersistence", diff --git a/UI/NoorUI/Components/NoorEducationNudge.swift b/UI/NoorUI/Components/NoorEducationNudge.swift new file mode 100644 index 000000000..3c06140be --- /dev/null +++ b/UI/NoorUI/Components/NoorEducationNudge.swift @@ -0,0 +1,85 @@ +// +// NoorEducationNudge.swift +// +// Created by OpenAI on 2026-05-09. +// + +import SwiftUI + +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 + } + .buttonStyle(.plain) + + Spacer(minLength: ContentDimension.interSpacing) + + Button(actionTitle) { + Task { + await action() + } + } + .font(.body.weight(.semibold)) + .foregroundColor(Color.accentColor) + } + + 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) + } +} From 6e821d840d14be4b975884e1396d9afef200c42c Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sun, 10 May 2026 00:54:18 +0300 Subject: [PATCH 13/21] Polish sync reading bookmark flow --- .../Resources/ar.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Sources/BookmarksBuilder.swift | 4 + .../Sources/BookmarksView.swift | 38 ++++++++- .../Sources/BookmarksViewModel.swift | 60 +++++++++++--- .../Sources/ReadingBookmarkService.swift | 79 ++++++++++++++----- .../Tests/ReadingBookmarkServiceTests.swift | 4 +- Features/HomeFeature/HomeBuilder.swift | 12 +++ Features/HomeFeature/HomeView.swift | 29 +++++++ Features/HomeFeature/HomeViewModel.swift | 36 ++++++++- .../QuranViewFeature/QuranInteractor.swift | 11 +-- Package.swift | 1 + 12 files changed, 233 insertions(+), 43 deletions(-) diff --git a/Core/Localization/Resources/ar.lproj/Localizable.strings b/Core/Localization/Resources/ar.lproj/Localizable.strings index 95971d6de..c4c2cb519 100644 --- a/Core/Localization/Resources/ar.lproj/Localizable.strings +++ b/Core/Localization/Resources/ar.lproj/Localizable.strings @@ -279,3 +279,4 @@ "reading-bookmark.education.title.link" = "علامة قراءة متنقلة"; "reading-bookmark.education.body" = "حفظ علامة قراءة سيستبدل علامة القراءة القديمة. لحفظ عدة آيات، استخدم ميزة التمييز."; "reading-bookmark.education.undo" = "تراجع"; +"reading-bookmark.title" = "علامة القراءة"; diff --git a/Core/Localization/Resources/en.lproj/Localizable.strings b/Core/Localization/Resources/en.lproj/Localizable.strings index 6bf290e2b..ce4bd3968 100644 --- a/Core/Localization/Resources/en.lproj/Localizable.strings +++ b/Core/Localization/Resources/en.lproj/Localizable.strings @@ -287,3 +287,4 @@ "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"; diff --git a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift index 3c98c25d2..05e5f13ef 100644 --- a/Features/BookmarksFeature/Sources/BookmarksBuilder.swift +++ b/Features/BookmarksFeature/Sources/BookmarksBuilder.swift @@ -28,7 +28,9 @@ 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) @@ -56,6 +58,7 @@ public struct BookmarksBuilder { navigationController.pushViewController(oldPageBookmarksViewController, animated: true) } } else { + readingBookmarkService = nil showCollectionsAction = nil showOldPageBookmarksAction = nil } @@ -66,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..f46196d6c 100644 --- a/Features/BookmarksFeature/Sources/BookmarksView.swift +++ b/Features/BookmarksFeature/Sources/BookmarksView.swift @@ -25,9 +25,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 +47,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 +61,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 +78,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 @@ -141,6 +153,12 @@ private struct BookmarksViewUI: View { collectionsRow } } + + if let readingBookmark { + NoorBasicSection { + readingBookmarkItem(readingBookmark) + } + } #endif if includeBookmarks { @@ -151,6 +169,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 +273,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/ReadingBookmarkService.swift b/Features/BookmarksFeature/Sources/ReadingBookmarkService.swift index 985a49ad4..52d0b4898 100644 --- a/Features/BookmarksFeature/Sources/ReadingBookmarkService.swift +++ b/Features/BookmarksFeature/Sources/ReadingBookmarkService.swift @@ -1,26 +1,53 @@ -#if QURAN_SYNC - // - // ReadingBookmarkService.swift - // - // Created by OpenAI on 2026-05-09. - // +// +// ReadingBookmarkService.swift +// +// Created by Ahmed Nabil on 2026-05-09. +// +import Foundation +#if QURAN_SYNC import MobileSync - import QuranKit import ReadingService +#endif +import QuranKit - public enum QuranReadingBookmark: Equatable { - case ayah(AyahNumber) - case page(Page) +public enum QuranReadingBookmark: Equatable { + case ayah(AyahNumber, Date) + case page(Page, Date) - public var page: Page { - switch self { - case .ayah(let ayah): ayah.page - case .page(let page): page - } + 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 isPageBookmark(for pages: [Page]) -> Bool { + pages.contains(page) + } +} + +#if QURAN_SYNC public struct ReadingBookmarkSequence: AsyncSequence { public typealias Element = QuranReadingBookmark? @@ -76,15 +103,19 @@ return ReadingBookmarkSequence(sequence) } - public func addReadingBookmark(page: Page) async throws { - _ = try await syncService.addPageReadingBookmark(page: Int32(page.pageNumber)) + @discardableResult + public func addReadingBookmark(page: Page) async throws -> QuranReadingBookmark { + let bookmark = try await syncService.addPageReadingBookmark(page: Int32(page.pageNumber)) + return .page(page, bookmark.lastUpdated) } - public func addReadingBookmark(ayah: AyahNumber) async throws { - _ = try await syncService.addAyahReadingBookmark( + @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 { @@ -94,9 +125,13 @@ 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) + 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) + return Page(quran: quran, pageNumber: Int(bookmark.page)).map { + QuranReadingBookmark.page($0, bookmark.lastUpdated) + } default: return nil } @@ -107,4 +142,6 @@ private let syncService: SyncService private let readingPreferences: ReadingPreferences } +#else + public struct ReadingBookmarkService {} #endif diff --git a/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift b/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift index 14854669a..46b3a5477 100644 --- a/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift +++ b/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift @@ -15,7 +15,7 @@ XCTAssertEqual( ReadingBookmarkService.bookmark(from: bookmark, quran: .hafsMadani1405), - .ayah(AyahNumber(quran: .hafsMadani1405, sura: 1, ayah: 1)!) + .ayah(AyahNumber(quran: .hafsMadani1405, sura: 1, ayah: 1)!, .distantPast) ) } @@ -28,7 +28,7 @@ XCTAssertEqual( ReadingBookmarkService.bookmark(from: bookmark, quran: .hafsMadani1405), - .page(Page(quran: .hafsMadani1405, pageNumber: 1)!) + .page(Page(quran: .hafsMadani1405, pageNumber: 1)!, .distantPast) ) } diff --git a/Features/HomeFeature/HomeBuilder.swift b/Features/HomeFeature/HomeBuilder.swift index 9701c43fc..887a96d3f 100644 --- a/Features/HomeFeature/HomeBuilder.swift +++ b/Features/HomeFeature/HomeBuilder.swift @@ -8,6 +8,7 @@ import AnnotationsService import AppDependencies +import BookmarksFeature import FeaturesSupport import QuranTextKit import ReadingSelectorFeature @@ -31,6 +32,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 +53,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..d8b0ae82f 100644 --- a/Features/HomeFeature/HomeView.swift +++ b/Features/HomeFeature/HomeView.swift @@ -5,6 +5,7 @@ // Created by Mohamed Afifi on 2023-07-16. // +import BookmarksFeature 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 { + 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..21c3580c8 100644 --- a/Features/HomeFeature/HomeViewModel.swift +++ b/Features/HomeFeature/HomeViewModel.swift @@ -6,6 +6,7 @@ // import AnnotationsService +import BookmarksFeature import Combine import Crashing import Foundation @@ -34,12 +35,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 +56,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 +84,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 +119,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 +139,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/QuranInteractor.swift b/Features/QuranViewFeature/QuranInteractor.swift index 2b3187a2a..2e2332ca3 100644 --- a/Features/QuranViewFeature/QuranInteractor.swift +++ b/Features/QuranViewFeature/QuranInteractor.swift @@ -556,8 +556,7 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis presenter?.hideReadingBookmarkNudge() } else { deps.analytics.bookmarkPage(page) - try await service.addReadingBookmark(page: page) - readingBookmark = .page(page) + readingBookmark = try await service.addReadingBookmark(page: page) showReadingBookmarkNudge(using: service) } } catch { @@ -566,7 +565,8 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis } private func showReadingBookmarkNudge(using service: ReadingBookmarkService) { - presenter?.showReadingBookmarkNudge(expanded: service.nextEducationPresentationIsExpanded()) { [weak self] in + let isExpanded = service.nextEducationPresentationIsExpanded() + presenter?.showReadingBookmarkNudge(expanded: isExpanded) { [weak self] in await self?.removeReadingBookmark(using: service) } } @@ -604,10 +604,7 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis #if QURAN_SYNC private func readingBookmarked(_ pages: [Page]) -> Bool { - guard case .page(let page) = readingBookmark else { - return false - } - return pages.contains(page) + readingBookmark?.isPageBookmark(for: pages) ?? false } #endif diff --git a/Package.swift b/Package.swift index bbdf014b6..711cc31dd 100644 --- a/Package.swift +++ b/Package.swift @@ -715,6 +715,7 @@ private func featuresTargets() -> [[Target]] { "AnnotationsService", "FeaturesSupport", "Preferences", + "BookmarksFeature", ]), target(type, name: "QuranViewFeature", hasTests: false, dependencies: [ From faeb6e43083ad75bb1a74cac03337940b2531c9d Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sun, 10 May 2026 02:18:59 +0300 Subject: [PATCH 14/21] Polish reading bookmark presentation --- Core/Localization/Resources/ar.lproj/Localizable.strings | 1 + Core/Localization/Resources/en.lproj/Localizable.strings | 1 + Features/BookmarksFeature/Sources/BookmarksView.swift | 2 +- .../Sources/ReadingBookmarkService.swift | 9 +++++++++ Features/HomeFeature/HomeView.swift | 2 +- UI/NoorUI/Components/NoorEducationNudge.swift | 4 ++-- 6 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Core/Localization/Resources/ar.lproj/Localizable.strings b/Core/Localization/Resources/ar.lproj/Localizable.strings index c4c2cb519..2075df5a0 100644 --- a/Core/Localization/Resources/ar.lproj/Localizable.strings +++ b/Core/Localization/Resources/ar.lproj/Localizable.strings @@ -280,3 +280,4 @@ "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 ce4bd3968..14dcb43ab 100644 --- a/Core/Localization/Resources/en.lproj/Localizable.strings +++ b/Core/Localization/Resources/en.lproj/Localizable.strings @@ -288,3 +288,4 @@ "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/Features/BookmarksFeature/Sources/BookmarksView.swift b/Features/BookmarksFeature/Sources/BookmarksView.swift index f46196d6c..5f76847de 100644 --- a/Features/BookmarksFeature/Sources/BookmarksView.swift +++ b/Features/BookmarksFeature/Sources/BookmarksView.swift @@ -155,7 +155,7 @@ private struct BookmarksViewUI: View { } if let readingBookmark { - NoorBasicSection { + NoorBasicSection(title: l("reading-bookmark.my-title")) { readingBookmarkItem(readingBookmark) } } diff --git a/Features/BookmarksFeature/Sources/ReadingBookmarkService.swift b/Features/BookmarksFeature/Sources/ReadingBookmarkService.swift index 52d0b4898..1736ff838 100644 --- a/Features/BookmarksFeature/Sources/ReadingBookmarkService.swift +++ b/Features/BookmarksFeature/Sources/ReadingBookmarkService.swift @@ -42,6 +42,15 @@ public enum QuranReadingBookmark: Equatable { 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 { pages.contains(page) } diff --git a/Features/HomeFeature/HomeView.swift b/Features/HomeFeature/HomeView.swift index d8b0ae82f..4e0de735c 100644 --- a/Features/HomeFeature/HomeView.swift +++ b/Features/HomeFeature/HomeView.swift @@ -56,7 +56,7 @@ private struct HomeViewUI: View { NoorList { #if QURAN_SYNC if let readingBookmark { - NoorBasicSection { + NoorBasicSection(title: l("reading-bookmark.my-title")) { readingBookmarkView(readingBookmark) } } diff --git a/UI/NoorUI/Components/NoorEducationNudge.swift b/UI/NoorUI/Components/NoorEducationNudge.swift index 3c06140be..852a87852 100644 --- a/UI/NoorUI/Components/NoorEducationNudge.swift +++ b/UI/NoorUI/Components/NoorEducationNudge.swift @@ -1,7 +1,7 @@ // // NoorEducationNudge.swift // -// Created by OpenAI on 2026-05-09. +// Created by Ahmed Nabil on 2026-05-09. // import SwiftUI @@ -43,7 +43,7 @@ public struct NoorEducationNudge: View { } } .font(.body.weight(.semibold)) - .foregroundColor(Color.accentColor) + .foregroundColor(Color.appIdentity) } if isExpanded { From d9bfc16bd6ca90ba14d5211c74ed9f2d8754eaf4 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sun, 10 May 2026 02:38:20 +0300 Subject: [PATCH 15/21] Keep reading bookmark nudge title on one line --- UI/NoorUI/Components/NoorEducationNudge.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/UI/NoorUI/Components/NoorEducationNudge.swift b/UI/NoorUI/Components/NoorEducationNudge.swift index 852a87852..ccb540eda 100644 --- a/UI/NoorUI/Components/NoorEducationNudge.swift +++ b/UI/NoorUI/Components/NoorEducationNudge.swift @@ -32,6 +32,8 @@ public struct NoorEducationNudge: View { HStack(spacing: ContentDimension.interSpacing) { Button(action: { isExpanded.toggle() }) { title + .lineLimit(1) + .minimumScaleFactor(0.75) } .buttonStyle(.plain) From 9cc3a7d118dfc2da10a6dd0755cc6f5094785fd7 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sun, 10 May 2026 02:47:59 +0300 Subject: [PATCH 16/21] Cover shared reading bookmark state --- .../Tests/ReadingBookmarkServiceTests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift b/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift index 46b3a5477..88a870eca 100644 --- a/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift +++ b/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift @@ -32,6 +32,20 @@ ) } + 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_matchesAyahBookmarkPage() { + let ayah = AyahNumber(quran: .hafsMadani1405, sura: 1, ayah: 1)! + let bookmark = QuranReadingBookmark.ayah(ayah, .distantPast) + + XCTAssertTrue(bookmark.isPageBookmark(for: [ayah.page])) + } + func test_bookmark_skipsInvalidAyah() { let bookmark = AyahReadingBookmark( sura: 999, From 3740321d705b5c99f21ca69692268255f68514ec Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sun, 10 May 2026 22:48:19 +0300 Subject: [PATCH 17/21] Move reading bookmark service to annotations domain --- .../Sources/ReadingBookmarkService.swift | 6 ------ .../Tests/ReadingBookmarkServiceTests.swift | 2 +- Features/BookmarksFeature/Sources/BookmarksView.swift | 1 + Features/HomeFeature/HomeBuilder.swift | 1 - Features/HomeFeature/HomeView.swift | 2 +- Features/HomeFeature/HomeViewModel.swift | 1 - Features/QuranViewFeature/QuranBuilder.swift | 3 --- Features/QuranViewFeature/QuranInteractor.swift | 3 ++- .../ReadingBookmarkPreferences.swift | 0 Package.swift | 4 +++- 10 files changed, 8 insertions(+), 15 deletions(-) rename {Features/BookmarksFeature => Domain/AnnotationsService}/Sources/ReadingBookmarkService.swift (94%) rename {Features/BookmarksFeature => Domain/AnnotationsService}/Tests/ReadingBookmarkServiceTests.swift (98%) rename Features/{BookmarksFeature/Sources => QuranViewFeature}/ReadingBookmarkPreferences.swift (100%) diff --git a/Features/BookmarksFeature/Sources/ReadingBookmarkService.swift b/Domain/AnnotationsService/Sources/ReadingBookmarkService.swift similarity index 94% rename from Features/BookmarksFeature/Sources/ReadingBookmarkService.swift rename to Domain/AnnotationsService/Sources/ReadingBookmarkService.swift index 1736ff838..a070638f4 100644 --- a/Features/BookmarksFeature/Sources/ReadingBookmarkService.swift +++ b/Domain/AnnotationsService/Sources/ReadingBookmarkService.swift @@ -98,12 +98,6 @@ public enum QuranReadingBookmark: Equatable { // MARK: Public - public func nextEducationPresentationIsExpanded() -> Bool { - let isExpanded = !ReadingBookmarkPreferences.shared.isEducationShown - ReadingBookmarkPreferences.shared.isEducationShown = true - return isExpanded - } - public func readingBookmarkSequence() -> ReadingBookmarkSequence { let sequence = syncService.readingBookmarkSequence() .map { bookmark in diff --git a/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift b/Domain/AnnotationsService/Tests/ReadingBookmarkServiceTests.swift similarity index 98% rename from Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift rename to Domain/AnnotationsService/Tests/ReadingBookmarkServiceTests.swift index 88a870eca..4a3744e1b 100644 --- a/Features/BookmarksFeature/Tests/ReadingBookmarkServiceTests.swift +++ b/Domain/AnnotationsService/Tests/ReadingBookmarkServiceTests.swift @@ -2,7 +2,7 @@ import MobileSync import QuranKit import XCTest - @testable import BookmarksFeature + @testable import AnnotationsService final class ReadingBookmarkServiceTests: XCTestCase { func test_bookmark_mapsAyahReadingBookmark() { diff --git a/Features/BookmarksFeature/Sources/BookmarksView.swift b/Features/BookmarksFeature/Sources/BookmarksView.swift index 5f76847de..0a0a41785 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 diff --git a/Features/HomeFeature/HomeBuilder.swift b/Features/HomeFeature/HomeBuilder.swift index 887a96d3f..81446a09b 100644 --- a/Features/HomeFeature/HomeBuilder.swift +++ b/Features/HomeFeature/HomeBuilder.swift @@ -8,7 +8,6 @@ import AnnotationsService import AppDependencies -import BookmarksFeature import FeaturesSupport import QuranTextKit import ReadingSelectorFeature diff --git a/Features/HomeFeature/HomeView.swift b/Features/HomeFeature/HomeView.swift index 4e0de735c..2c221d6e3 100644 --- a/Features/HomeFeature/HomeView.swift +++ b/Features/HomeFeature/HomeView.swift @@ -5,7 +5,7 @@ // Created by Mohamed Afifi on 2023-07-16. // -import BookmarksFeature +import AnnotationsService import Localization import NoorUI import QuranAnnotations diff --git a/Features/HomeFeature/HomeViewModel.swift b/Features/HomeFeature/HomeViewModel.swift index 21c3580c8..bda209edf 100644 --- a/Features/HomeFeature/HomeViewModel.swift +++ b/Features/HomeFeature/HomeViewModel.swift @@ -6,7 +6,6 @@ // import AnnotationsService -import BookmarksFeature import Combine import Crashing import Foundation diff --git a/Features/QuranViewFeature/QuranBuilder.swift b/Features/QuranViewFeature/QuranBuilder.swift index 21f965a3d..51b42aef0 100644 --- a/Features/QuranViewFeature/QuranBuilder.swift +++ b/Features/QuranViewFeature/QuranBuilder.swift @@ -10,9 +10,6 @@ import AnnotationsService import AppDependencies import AudioBannerFeature import AyahMenuFeature -#if QURAN_SYNC - import BookmarksFeature -#endif import MoreMenuFeature import NoteEditorFeature import QuranContentFeature diff --git a/Features/QuranViewFeature/QuranInteractor.swift b/Features/QuranViewFeature/QuranInteractor.swift index 2e2332ca3..d3a4f60cb 100644 --- a/Features/QuranViewFeature/QuranInteractor.swift +++ b/Features/QuranViewFeature/QuranInteractor.swift @@ -565,7 +565,8 @@ final class QuranInteractor: WordPointerListener, ContentListener, NoteEditorLis } private func showReadingBookmarkNudge(using service: ReadingBookmarkService) { - let isExpanded = service.nextEducationPresentationIsExpanded() + let isExpanded = !ReadingBookmarkPreferences.shared.isEducationShown + ReadingBookmarkPreferences.shared.isEducationShown = true presenter?.showReadingBookmarkNudge(expanded: isExpanded) { [weak self] in await self?.removeReadingBookmark(using: service) } diff --git a/Features/BookmarksFeature/Sources/ReadingBookmarkPreferences.swift b/Features/QuranViewFeature/ReadingBookmarkPreferences.swift similarity index 100% rename from Features/BookmarksFeature/Sources/ReadingBookmarkPreferences.swift rename to Features/QuranViewFeature/ReadingBookmarkPreferences.swift diff --git a/Package.swift b/Package.swift index 711cc31dd..6eb1f4aa2 100644 --- a/Package.swift +++ b/Package.swift @@ -161,6 +161,7 @@ private func modelTargets() -> [[Target]] { target(type, name: "QuranKit"), target(type, name: "QuranGeometry", hasTests: false, dependencies: [ "QuranKit", + .product(name: "MobileSync", package: "mobile-sync-spm"), ]), target(type, name: "QuranAudio", hasTests: false, dependencies: [ "Utilities", @@ -512,11 +513,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: [ @@ -715,7 +718,6 @@ private func featuresTargets() -> [[Target]] { "AnnotationsService", "FeaturesSupport", "Preferences", - "BookmarksFeature", ]), target(type, name: "QuranViewFeature", hasTests: false, dependencies: [ From c256d2bd357889553d3399cf8ad4e6b2017ae35d Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sun, 10 May 2026 23:06:17 +0300 Subject: [PATCH 18/21] Clean up reading bookmark dependencies --- Features/QuranViewFeature/ReadingBookmarkPreferences.swift | 2 -- Package.swift | 1 - 2 files changed, 3 deletions(-) diff --git a/Features/QuranViewFeature/ReadingBookmarkPreferences.swift b/Features/QuranViewFeature/ReadingBookmarkPreferences.swift index 91e818fb4..ec38fb683 100644 --- a/Features/QuranViewFeature/ReadingBookmarkPreferences.swift +++ b/Features/QuranViewFeature/ReadingBookmarkPreferences.swift @@ -2,8 +2,6 @@ // // ReadingBookmarkPreferences.swift // - // Created by OpenAI on 2026-05-09. - // import Preferences diff --git a/Package.swift b/Package.swift index 6eb1f4aa2..2cb89fb07 100644 --- a/Package.swift +++ b/Package.swift @@ -161,7 +161,6 @@ private func modelTargets() -> [[Target]] { target(type, name: "QuranKit"), target(type, name: "QuranGeometry", hasTests: false, dependencies: [ "QuranKit", - .product(name: "MobileSync", package: "mobile-sync-spm"), ]), target(type, name: "QuranAudio", hasTests: false, dependencies: [ "Utilities", From 71b2bd646449d9bf56ff8e9b0b361d76a3b8e6c7 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Mon, 11 May 2026 01:19:12 +0300 Subject: [PATCH 19/21] Fix reading bookmark presentation state --- .../Sources/ReadingBookmarkService.swift | 5 ++++- .../Tests/ReadingBookmarkServiceTests.swift | 4 ++-- .../BookmarksFeature/Sources/BookmarksView.swift | 12 ++++++------ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Domain/AnnotationsService/Sources/ReadingBookmarkService.swift b/Domain/AnnotationsService/Sources/ReadingBookmarkService.swift index a070638f4..f37a81264 100644 --- a/Domain/AnnotationsService/Sources/ReadingBookmarkService.swift +++ b/Domain/AnnotationsService/Sources/ReadingBookmarkService.swift @@ -52,7 +52,10 @@ public enum QuranReadingBookmark: Equatable { } public func isPageBookmark(for pages: [Page]) -> Bool { - pages.contains(page) + guard case .page(let page, _) = self else { + return false + } + return pages.contains(page) } } diff --git a/Domain/AnnotationsService/Tests/ReadingBookmarkServiceTests.swift b/Domain/AnnotationsService/Tests/ReadingBookmarkServiceTests.swift index 4a3744e1b..88fa0c1be 100644 --- a/Domain/AnnotationsService/Tests/ReadingBookmarkServiceTests.swift +++ b/Domain/AnnotationsService/Tests/ReadingBookmarkServiceTests.swift @@ -39,11 +39,11 @@ XCTAssertTrue(bookmark.isReadingBookmark(for: ayah)) } - func test_isPageBookmark_matchesAyahBookmarkPage() { + func test_isPageBookmark_doesNotMatchAyahBookmarkPage() { let ayah = AyahNumber(quran: .hafsMadani1405, sura: 1, ayah: 1)! let bookmark = QuranReadingBookmark.ayah(ayah, .distantPast) - XCTAssertTrue(bookmark.isPageBookmark(for: [ayah.page])) + XCTAssertFalse(bookmark.isPageBookmark(for: [ayah.page])) } func test_bookmark_skipsInvalidAyah() { diff --git a/Features/BookmarksFeature/Sources/BookmarksView.swift b/Features/BookmarksFeature/Sources/BookmarksView.swift index 0a0a41785..59df6c0f4 100644 --- a/Features/BookmarksFeature/Sources/BookmarksView.swift +++ b/Features/BookmarksFeature/Sources/BookmarksView.swift @@ -143,6 +143,12 @@ private struct BookmarksViewUI: View { } } + if let readingBookmark { + NoorBasicSection(title: l("reading-bookmark.my-title")) { + readingBookmarkItem(readingBookmark) + } + } + if showOldPageBookmarksAction != nil { NoorBasicSection { oldPageBookmarksRow @@ -154,12 +160,6 @@ private struct BookmarksViewUI: View { collectionsRow } } - - if let readingBookmark { - NoorBasicSection(title: l("reading-bookmark.my-title")) { - readingBookmarkItem(readingBookmark) - } - } #endif if includeBookmarks { From 83df64ee84074073fc66b4861317d4ca10b69495 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Mon, 11 May 2026 02:11:52 +0300 Subject: [PATCH 20/21] Use AsyncButton in reading bookmark nudge --- UI/NoorUI/Components/NoorEducationNudge.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/UI/NoorUI/Components/NoorEducationNudge.swift b/UI/NoorUI/Components/NoorEducationNudge.swift index ccb540eda..03cd6e695 100644 --- a/UI/NoorUI/Components/NoorEducationNudge.swift +++ b/UI/NoorUI/Components/NoorEducationNudge.swift @@ -5,6 +5,7 @@ // import SwiftUI +import UIx public struct NoorEducationNudge: View { // MARK: Lifecycle @@ -39,13 +40,11 @@ public struct NoorEducationNudge: View { Spacer(minLength: ContentDimension.interSpacing) - Button(actionTitle) { - Task { - await action() - } + AsyncButton(action: action) { + Text(actionTitle) + .font(.body.weight(.semibold)) + .foregroundColor(Color.appIdentity) } - .font(.body.weight(.semibold)) - .foregroundColor(Color.appIdentity) } if isExpanded { From cca2d75314f92ad64c716b905cfab14e155bbb53 Mon Sep 17 00:00:00 2001 From: Ahmed Nabil Date: Sat, 23 May 2026 23:51:48 +0300 Subject: [PATCH 21/21] add missing import --- Features/QuranViewFeature/QuranBuilder.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Features/QuranViewFeature/QuranBuilder.swift b/Features/QuranViewFeature/QuranBuilder.swift index 51b42aef0..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