diff --git a/Core/Localization/Resources/ar.lproj/Localizable.strings b/Core/Localization/Resources/ar.lproj/Localizable.strings index 04adb7d8..a6196057 100644 --- a/Core/Localization/Resources/ar.lproj/Localizable.strings +++ b/Core/Localization/Resources/ar.lproj/Localizable.strings @@ -290,3 +290,4 @@ "ayah-bookmark.remove-reading-bookmark" = "إزالة علامة القراءة"; "ayah-bookmark.collection-picker.no-data.title" = "لا توجد مجموعات بعد"; "ayah-bookmark.collection-picker.no-data.text" = "أضف مجموعة لحفظ هذه الآية."; +"bookmarks.collections.favourites" = "المفضلة"; diff --git a/Core/Localization/Resources/en.lproj/Localizable.strings b/Core/Localization/Resources/en.lproj/Localizable.strings index 58c93e74..76c02c80 100644 --- a/Core/Localization/Resources/en.lproj/Localizable.strings +++ b/Core/Localization/Resources/en.lproj/Localizable.strings @@ -298,3 +298,4 @@ "ayah-bookmark.remove-reading-bookmark" = "Remove Reading Bookmark"; "ayah-bookmark.collection-picker.no-data.title" = "No collections yet"; "ayah-bookmark.collection-picker.no-data.text" = "Add a collection to save this verse."; +"bookmarks.collections.favourites" = "Favourites"; diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionPickerViewModel.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionPickerViewModel.swift index fe784f61..2148f338 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionPickerViewModel.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionPickerViewModel.swift @@ -7,6 +7,7 @@ import AnnotationsService import Foundation + import Localization import QuranAnnotations import QuranKit import VLogging @@ -75,9 +76,10 @@ } func start() async { - async let collections: () = loadCollections() - async let readingBookmark: () = loadReadingBookmark() - _ = await [collections, readingBookmark] + async let collections: Void = loadCollections() + async let bookmarks: Void = loadBookmarks() + async let readingBookmark: Void = loadReadingBookmark() + _ = await [collections, bookmarks, readingBookmark] } func toggleSelection(for collection: AyahBookmarkCollection) { @@ -147,13 +149,15 @@ private let didFinish: () -> Void private var didEnsureHighlightCollections = false private var didUpdateSelection = false + private var syncedCollections: [AyahBookmarkCollection] = [] + private var directBookmarks: [AyahCollectionBookmark] = [] private func loadCollections() async { do { let sequence = ayahBookmarkCollectionService.collectionsSequence() for try await collections in sequence { - self.collections = Self.sorted(collections) - selectExistingBookmarksIfNeeded(in: self.collections) + syncedCollections = collections + refreshCollections() try await ensureHighlightCollections(collections) } } catch { @@ -161,6 +165,32 @@ } } + private func loadBookmarks() async { + do { + let sequence = ayahBookmarkCollectionService.bookmarksSequence() + for try await bookmarks in sequence { + directBookmarks = bookmarks + refreshCollections() + } + } catch { + self.error = error + } + } + + private func refreshCollections() { + let collections = syncedCollections + [favouritesCollection()] + self.collections = Self.sorted(collections) + selectExistingBookmarksIfNeeded(in: self.collections) + } + + private func favouritesCollection() -> AyahBookmarkCollection { + FavouritesBookmarkCollection.make( + name: l("bookmarks.collections.favourites"), + bookmarks: directBookmarks, + collections: syncedCollections + ) + } + private func ensureHighlightCollections(_ collections: [AyahBookmarkCollection]) async throws { guard !didEnsureHighlightCollections else { return @@ -225,10 +255,14 @@ for collection in bookmarkCollections { if selectedCollectionIDs.contains(collection.collection.localId) { for verse in Self.bookmarksToAdd(to: collection, verses: verses) { - try await ayahBookmarkCollectionService.addAyahBookmarkToCollection( - collectionLocalId: collection.collection.localId, - ayah: verse - ) + if collection.isLocalOnly { + try await ayahBookmarkCollectionService.addAyahBookmark(verse) + } else { + try await ayahBookmarkCollectionService.addAyahBookmarkToCollection( + collectionLocalId: collection.collection.localId, + ayah: verse + ) + } } } else { for bookmark in Self.bookmarksToRemove(from: collection, verses: verses) { diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionService.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionService.swift index 15fd5aca..5336a28f 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionService.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionService.swift @@ -12,11 +12,33 @@ public struct AyahBookmarkCollection { public let collection: Collection_ public let bookmarks: [AyahCollectionBookmark] + public let isLocalOnly: Bool + + public init(collection: Collection_, bookmarks: [AyahCollectionBookmark], isLocalOnly: Bool = false) { + self.collection = collection + self.bookmarks = bookmarks + self.isLocalOnly = isLocalOnly + } } public struct AyahCollectionBookmark { - public let bookmark: CollectionAyahBookmark + public enum Bookmark { + case collection(CollectionAyahBookmark) + case ayah(AyahBookmark) + } + + public let bookmark: Bookmark public let ayah: AyahNumber + + public init(bookmark: CollectionAyahBookmark, ayah: AyahNumber) { + self.bookmark = .collection(bookmark) + self.ayah = ayah + } + + public init(bookmark: AyahBookmark, ayah: AyahNumber) { + self.bookmark = .ayah(bookmark) + self.ayah = ayah + } } public struct AyahBookmarkCollectionsSequence: AsyncSequence { @@ -50,6 +72,37 @@ private let makeIterator: () -> AsyncIterator } + public struct AyahBookmarksSequence: AsyncSequence { + public typealias Element = [AyahCollectionBookmark] + + 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 AyahBookmarkCollectionService { // MARK: Lifecycle @@ -67,6 +120,13 @@ try await syncService.createCollection(named: name) } + public func addAyahBookmark(_ ayah: AyahNumber) async throws { + _ = try await syncService.addAyahBookmark( + sura: Int32(ayah.sura.suraNumber), + ayah: Int32(ayah.ayah) + ) + } + public func addAyahBookmarkToCollection(collectionLocalId: String, ayah: AyahNumber) async throws { _ = try await syncService.addAyahBookmarkToCollection( collectionLocalId: collectionLocalId, @@ -80,7 +140,12 @@ } public func removeBookmarkFromCollection(_ bookmark: AyahCollectionBookmark) async throws { - try await syncService.removeAyahBookmarkFromCollection(bookmark.bookmark) + switch bookmark.bookmark { + case .collection(let bookmark): + try await syncService.removeAyahBookmarkFromCollection(bookmark) + case .ayah(let bookmark): + try await syncService.removeBookmark(bookmark) + } } public func collectionsSequence() -> AyahBookmarkCollectionsSequence { @@ -95,6 +160,15 @@ return AyahBookmarkCollectionsSequence(sequence) } + public func bookmarksSequence() -> AyahBookmarksSequence { + let readingPreferences = readingPreferences + let sequence = syncService.bookmarksSequence() + .map { bookmarks in + Self.bookmarks(from: bookmarks, quran: readingPreferences.reading.quran) + } + return AyahBookmarksSequence(sequence) + } + // MARK: Internal static func collections(from collections: [CollectionWithAyahBookmarks], quran: Quran) -> [AyahBookmarkCollection] { @@ -106,6 +180,10 @@ } } + static func bookmarks(from bookmarks: [AyahBookmark], quran: Quran) -> [AyahCollectionBookmark] { + bookmarks.compactMap { bookmark(for: $0, quran: quran) } + } + // MARK: Private private let syncService: SyncService @@ -122,6 +200,18 @@ return AyahCollectionBookmark(bookmark: bookmark, ayah: ayah) } + + private static func bookmark(for bookmark: AyahBookmark, quran: Quran) -> AyahCollectionBookmark? { + guard let ayah = AyahNumber( + quran: quran, + sura: Int(bookmark.sura), + ayah: Int(bookmark.ayah) + ) else { + return nil + } + + return AyahCollectionBookmark(bookmark: bookmark, ayah: ayah) + } } #endif diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsModels+Identifiable.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsModels+Identifiable.swift index aac976f2..2f68ccc3 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsModels+Identifiable.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsModels+Identifiable.swift @@ -1,11 +1,16 @@ #if QURAN_SYNC - import MobileSync - extension AyahBookmarkCollection: Identifiable { public var id: String { collection.localId } } extension AyahCollectionBookmark: Identifiable { - public var id: String { bookmark.localId } + public var id: String { + switch bookmark { + case .collection(let bookmark): + return bookmark.localId + case .ayah(let bookmark): + return bookmark.localId + } + } } #endif diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift index 88772ad6..dc5cb0bb 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsView.swift @@ -84,7 +84,7 @@ ) let highlightColor = HighlightColor(collectionName: collection.collection.name) - let allowsCollectionDeletion = allowsCollectionManagement && highlightColor == nil + let allowsCollectionDeletion = allowsCollectionManagement && highlightColor == nil && !collection.isLocalOnly NoorEditableCollapsibleSection( title: highlightColor?.localizedName ?? collection.collection.name, diff --git a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift index 2cc7a00d..5497ef55 100644 --- a/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift +++ b/Features/BookmarksFeature/Sources/AyahBookmarkCollectionsViewModel.swift @@ -6,6 +6,7 @@ // import Foundation + import Localization import QuranAnnotations import QuranKit import SwiftUI @@ -52,15 +53,9 @@ } func start() async { - do { - let sequence = ayahBookmarkCollectionService.collectionsSequence() - for try await collections in sequence { - self.collections = Self.sorted(filtered(collections)) - try await prepareCollectionsIfNeeded(collections) - } - } catch { - self.error = error - } + async let collections: Void = observeCollections() + async let bookmarks: Void = observeBookmarks() + _ = await [collections, bookmarks] } func isCollectionExpanded(_ collection: AyahBookmarkCollection) -> Bool { @@ -90,6 +85,10 @@ } func deleteCollection(_ collection: AyahBookmarkCollection) async { + guard !collection.isLocalOnly else { + return + } + do { try await ayahBookmarkCollectionService.removeCollection(localId: collection.collection.localId) } catch { @@ -113,6 +112,8 @@ private let prepareCollections: ([AyahBookmarkCollection]) async throws -> Void private let navigateToPage: (Page) -> Void private var didPrepareCollections = false + private var syncedCollections: [AyahBookmarkCollection] = [] + private var directBookmarks: [AyahCollectionBookmark] = [] private nonisolated static func highlightSortIndex(_ collection: AyahBookmarkCollection) -> Int? { guard let color = HighlightColor(collectionName: collection.collection.name) else { @@ -121,6 +122,43 @@ return HighlightColor.allCases.firstIndex(of: color) } + private func observeCollections() async { + do { + let sequence = ayahBookmarkCollectionService.collectionsSequence() + for try await collections in sequence { + syncedCollections = collections + refreshCollections() + try await prepareCollectionsIfNeeded(collections) + } + } catch { + self.error = error + } + } + + private func observeBookmarks() async { + do { + let sequence = ayahBookmarkCollectionService.bookmarksSequence() + for try await bookmarks in sequence { + directBookmarks = bookmarks + refreshCollections() + } + } catch { + self.error = error + } + } + + private func refreshCollections() { + collections = Self.sorted(filtered(syncedCollections + [favouritesCollection()])) + } + + private func favouritesCollection() -> AyahBookmarkCollection { + FavouritesBookmarkCollection.make( + name: l("bookmarks.collections.favourites"), + bookmarks: directBookmarks, + collections: syncedCollections + ) + } + private func filtered(_ collections: [AyahBookmarkCollection]) -> [AyahBookmarkCollection] { collections.filter { collection in let name = collection.collection.name diff --git a/Features/BookmarksFeature/Sources/FavouritesBookmarkCollection.swift b/Features/BookmarksFeature/Sources/FavouritesBookmarkCollection.swift new file mode 100644 index 00000000..289abac8 --- /dev/null +++ b/Features/BookmarksFeature/Sources/FavouritesBookmarkCollection.swift @@ -0,0 +1,43 @@ +#if QURAN_SYNC + // + // FavouritesBookmarkCollection.swift + // + // Created by Ahmed Nabil on 2026-05-16. + // + + import MobileSync + + enum FavouritesBookmarkCollection { + static let localId = "local-favourites" + + static func make( + name: String, + bookmarks: [AyahCollectionBookmark], + collections: [AyahBookmarkCollection] + ) -> AyahBookmarkCollection { + let linkedBookmarkIDs = Set(collections.flatMap { collection in + collection.bookmarks.compactMap { bookmark -> String? in + guard case .collection(let collectionBookmark) = bookmark.bookmark else { + return nil + } + return collectionBookmark.bookmarkLocalId + } + }) + + return AyahBookmarkCollection( + collection: Collection_(name: name, lastUpdated: .distantPast, localId: localId), + bookmarks: bookmarks.filter { bookmark in + guard case .ayah(let ayahBookmark) = bookmark.bookmark else { + return false + } + return !linkedBookmarkIDs.contains(ayahBookmark.localId) + }, + isLocalOnly: true + ) + } + + static func isFavourites(_ collection: AyahBookmarkCollection) -> Bool { + collection.collection.localId == localId + } + } +#endif diff --git a/Features/BookmarksFeature/Tests/AyahBookmarkCollectionPickerViewModelTests.swift b/Features/BookmarksFeature/Tests/AyahBookmarkCollectionPickerViewModelTests.swift index 4f3db32d..c9f41125 100644 --- a/Features/BookmarksFeature/Tests/AyahBookmarkCollectionPickerViewModelTests.swift +++ b/Features/BookmarksFeature/Tests/AyahBookmarkCollectionPickerViewModelTests.swift @@ -60,7 +60,7 @@ verses: [existingAyah, newAyah] ) - XCTAssertEqual(bookmarks.map(\.bookmark.localId), ["collection-bookmark-1"]) + XCTAssertEqual(bookmarks.map(\.id), ["collection-bookmark-1"]) } func test_containsAnyBookmark_detectsExistingBookmark() { diff --git a/Features/BookmarksFeature/Tests/AyahBookmarkCollectionServiceTests.swift b/Features/BookmarksFeature/Tests/AyahBookmarkCollectionServiceTests.swift index 6a7830e1..dd33bd4b 100644 --- a/Features/BookmarksFeature/Tests/AyahBookmarkCollectionServiceTests.swift +++ b/Features/BookmarksFeature/Tests/AyahBookmarkCollectionServiceTests.swift @@ -5,6 +5,8 @@ @testable import BookmarksFeature final class AyahBookmarkCollectionServiceTests: XCTestCase { + // MARK: Internal + func test_collections_mapsAyahNumbers() { let collections = AyahBookmarkCollectionService.collections(from: [ Self.collection( @@ -35,6 +37,39 @@ XCTAssertTrue(collections[0].bookmarks.isEmpty) } + func test_bookmarks_mapsAyahNumbers() { + let bookmarks = AyahBookmarkCollectionService.bookmarks(from: [ + Self.ayahBookmark(sura: 1, ayah: 1), + ], quran: .hafsMadani1405) + + XCTAssertEqual(bookmarks.count, 1) + XCTAssertEqual(bookmarks[0].ayah, AyahNumber(quran: .hafsMadani1405, sura: 1, ayah: 1)) + } + + func test_favourites_skipsBookmarksAlreadyLinkedToCollections() { + let quran = Quran.hafsMadani1405 + let linkedBookmark = Self.ayahBookmark(localId: "bookmark-1", sura: 1, ayah: 1) + let unlinkedBookmark = Self.ayahBookmark(localId: "bookmark-2", sura: 1, ayah: 2) + let directBookmarks = AyahBookmarkCollectionService.bookmarks(from: [linkedBookmark, unlinkedBookmark], quran: quran) + let collections = AyahBookmarkCollectionService.collections(from: [ + Self.collection( + name: "Collection", + bookmarks: [Self.bookmark(collectionLocalId: "collection", bookmarkLocalId: "bookmark-1", sura: 1, ayah: 1)] + ), + ], quran: quran) + + let favourites = FavouritesBookmarkCollection.make( + name: "Favourites", + bookmarks: directBookmarks, + collections: collections + ) + + XCTAssertEqual(favourites.bookmarks.map(\.ayah), [AyahNumber(quran: quran, sura: 1, ayah: 2)!]) + XCTAssertTrue(favourites.isLocalOnly) + } + + // MARK: Private + private static func collection( localId: String? = nil, name: String, @@ -50,6 +85,19 @@ ) } + private static func ayahBookmark( + localId: String? = nil, + sura: Int32, + ayah: Int32 + ) -> AyahBookmark { + AyahBookmark( + sura: sura, + ayah: ayah, + lastUpdated: .distantPast, + localId: localId ?? "bookmark-\(sura)-\(ayah)" + ) + } + private static func bookmark( collectionLocalId: String, bookmarkLocalId: String? = nil,