Skip to content
2 changes: 1 addition & 1 deletion DBMultiverse.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,7 @@
repositoryURL = "https://github.com/nikolainobadi/NnTestKit";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.3.0;
minimumVersion = 2.0.0;
};
};
FEBF4AF02EF9054500614481 /* XCRemoteSwiftPackageReference "NnSwiftDataKit" */ = {
Expand Down
9 changes: 8 additions & 1 deletion DBMultiverse/Sources/App/Launch/LaunchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct LaunchView: View {
@AppStorage("selectedLanguage") private var language: ComicLanguage = .english

var body: some View {
MainFeaturesView(language: $language, viewModel: .init(loader: ChapterLoaderAdapter()))
MainFeaturesView(language: $language, viewModel: .customInit())
.showingConditionalView(when: isInitialLogin) {
WelcomeView(language: $language) {
isInitialLogin = false
Expand All @@ -23,6 +23,13 @@ struct LaunchView: View {
}


private extension MainFeaturesViewModel {
static func customInit() -> MainFeaturesViewModel {
return .init(loader: ChapterLoaderAdapter(), widgetTimelineReloader: WidgetTimelineManager())
}
}


#if DEBUG
// MARK: - Preview
#Preview {
Expand Down
14 changes: 13 additions & 1 deletion DBMultiverse/Sources/Features/Comic/ComicImageCacheManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,21 @@ struct ComicImageCacheManager {
/// The delegate for managing cover images and progress metadata.
private let coverImageDelegate: CoverImageDelegate

/// Handles throttled widget timeline reloads to keep the widget in sync.
private let widgetTimelineReloader: WidgetTimelineReloader

/// Initializes the `ComicImageCacheManager` with its dependencies.
/// - Parameters:
/// - comicType: The type of comic (story or specials).
/// - store: The store for managing chapter progress.
/// - fileSystemOperations: The file system operations abstraction.
/// - coverImageDelegate: The delegate for cover image operations.
init(comicType: ComicType, store: ComicPageStore, fileSystemOperations: FileSystemOperations, coverImageDelegate: CoverImageDelegate) {
init(comicType: ComicType, store: ComicPageStore, fileSystemOperations: FileSystemOperations, coverImageDelegate: CoverImageDelegate, widgetTimelineReloader: WidgetTimelineReloader) {
self.comicType = comicType
self.store = store
self.fileSystemOperations = fileSystemOperations
self.coverImageDelegate = coverImageDelegate
self.widgetTimelineReloader = widgetTimelineReloader
}
}

Expand All @@ -45,6 +49,7 @@ extension ComicImageCacheManager: ComicImageCache {
func updateCurrentPageNumber(_ pageNumber: Int, readProgress: Int) {
coverImageDelegate.updateProgress(to: readProgress)
store.updateCurrentPageNumber(pageNumber, comicType: comicType)
widgetTimelineReloader.notifyProgressChange(progress: readProgress)
}

/// Saves a chapter cover image and its metadata to the cache.
Expand All @@ -54,6 +59,7 @@ extension ComicImageCacheManager: ComicImageCache {
/// - Throws: An error if the image data cannot be saved.
func saveChapterCoverImage(imageData: Data, metadata: CoverImageMetaData) throws {
coverImageDelegate.saveCurrentChapterData(imageData: imageData, metadata: metadata)
widgetTimelineReloader.notifyChapterChange(chapter: metadata.chapterNumber, progress: metadata.readProgress)
}

/// Loads a cached image for a specific chapter and page.
Expand Down Expand Up @@ -152,6 +158,12 @@ protocol CoverImageDelegate {
func saveCurrentChapterData(imageData: Data, metadata: CoverImageMetaData)
}

@MainActor
protocol WidgetTimelineReloader: AnyObject {
func notifyChapterChange(chapter: Int, progress: Int)
func notifyProgressChange(progress: Int)
}

protocol FileSystemOperations: Sendable {
func write(data: Data, to url: URL) throws
func contents(atPath path: String) -> Data?
Expand Down
28 changes: 21 additions & 7 deletions DBMultiverse/Sources/Features/Main/MainFeaturesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,13 @@ private extension ComicPageViewModel {
let currentPageNumber = store.getCurrentPageNumber(for: route.comicType)
let fileSystemOperations = FileSystemOperationsAdapter()
let coverImageDelegate = CoverImageDelegateAdapter()
let imageCache = ComicImageCacheManager(comicType: route.comicType, store: store, fileSystemOperations: fileSystemOperations, coverImageDelegate: coverImageDelegate)
let imageCache = ComicImageCacheManager(
comicType: route.comicType,
store: store,
fileSystemOperations: fileSystemOperations,
coverImageDelegate: coverImageDelegate,
widgetTimelineReloader: store.widgetTimelineReloader
)
let networkService = ComicPageNetworkServiceAdapter()
let manager = ComicPageManager(
chapter: route.chapter,
Expand All @@ -87,7 +93,7 @@ private extension ComicPageViewModel {
networkService: networkService,
chapterProgressHandler: chapterList
)

return .init(chapter: route.chapter, currentPageNumber: currentPageNumber, delegate: manager)
}
}
Expand All @@ -97,11 +103,19 @@ private extension ComicPageViewModel {
#if DEBUG
// MARK: - Preview
#Preview {
struct PreviewLoader: ChapterLoader {
func loadChapters(url: URL?) async throws -> [Chapter] { [] }
}

return MainFeaturesView(language: .constant(.english), viewModel: .init(loader: PreviewLoader()))
MainFeaturesView(language: .constant(.english), viewModel: .previewInit(delegate: PreviewDelegate()))
.withPreviewModifiers()
}

private final class PreviewDelegate: ChapterLoader, WidgetTimelineReloader {
func notifyProgressChange(progress: Int) { }
func notifyChapterChange(chapter: Int, progress: Int) { }
func loadChapters(url: URL?) async throws -> [Chapter] { [] }
}

private extension MainFeaturesViewModel {
static func previewInit(delegate: PreviewDelegate) -> MainFeaturesViewModel {
return .init(loader: delegate, widgetTimelineReloader: delegate)
}
}
#endif
11 changes: 7 additions & 4 deletions DBMultiverse/Sources/Features/Main/MainFeaturesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,19 @@ final class MainFeaturesViewModel: ObservableObject {
@Published var nextChapterToRead: Chapter?
@AppStorage var lastReadSpecialPage: Int
@AppStorage var lastReadMainStoryPage: Int

private let loader: ChapterLoader

let widgetTimelineReloader: WidgetTimelineReloader

/// Initializes the `MainFeaturesViewModel`.
/// - Parameters:
/// - loader: A dependency responsible for fetching chapter data.
/// - widgetTimelineReloader: The widget timeline reloader for syncing widget state. Defaults to a new `WidgetTimelineReloader` instance.
/// - userDefaults: The `UserDefaults` instance to store and retrieve page tracking data. Defaults to `.standard`.
init(loader: ChapterLoader, userDefaults: UserDefaults? = .standard) {
init(loader: ChapterLoader, widgetTimelineReloader: WidgetTimelineReloader, userDefaults: UserDefaults? = .standard) {
self.loader = loader

self.widgetTimelineReloader = widgetTimelineReloader

// Initialize AppStorage properties with custom or standard UserDefaults.
self._lastReadSpecialPage = .init(wrappedValue: 168, .lastReadSpecialPage, store: userDefaults)
self._lastReadMainStoryPage = .init(wrappedValue: 0, .lastReadMainStoryPage, store: userDefaults)
Expand Down
37 changes: 31 additions & 6 deletions DBMultiverse/Sources/Widgets/WidgetSyncViewModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,45 @@ import DBMultiverseComicKit

struct WidgetSyncViewModifier: ViewModifier {
@Environment(\.scenePhase) private var scenePhase
@State private var lastSavedChapterData: CurrentChapterData?
@State private var lastSyncedState: WidgetSyncState?

private let coverImageManager = CoverImageManager()

func body(content: Content) -> some View {
content
.onChange(of: scenePhase) {
// to prevent widget from being reloaded too often
if let currentChapterData = CoverImageManager().loadCurrentChapterData(), lastSavedChapterData != currentChapterData {
WidgetCenter.shared.reloadAllTimelines()
lastSavedChapterData = currentChapterData
.onAppear(perform: cacheLastSyncedStateIfNeeded)
.onChange(of: scenePhase) { _, newValue in
if newValue == .background {
syncWidgetTimelineIfNeeded()
}
}
}
}


// MARK: - Private Methods
private extension WidgetSyncViewModifier {
func cacheLastSyncedStateIfNeeded() {
if lastSyncedState == nil {
lastSyncedState = coverImageManager.loadWidgetSyncState()
}
}

func syncWidgetTimelineIfNeeded() {
guard let currentChapterData = coverImageManager.loadCurrentChapterData() else {
return
}

let currentState = WidgetSyncState(chapter: currentChapterData.number, progress: currentChapterData.progress)

if currentState != lastSyncedState {
lastSyncedState = currentState
coverImageManager.saveWidgetSyncState(currentState)
WidgetCenter.shared.reloadTimelines(ofKind: WIDGET_KIND)
}
}
}

extension View {
func syncWidgetData() -> some View {
modifier(WidgetSyncViewModifier())
Expand Down
76 changes: 76 additions & 0 deletions DBMultiverse/Sources/Widgets/WidgetTimelineManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//
// WidgetTimelineReloader.swift
// DBMultiverse
//
// Created by Nikolai Nobadi on 1/11/25.
//

import WidgetKit
import Foundation
import DBMultiverseComicKit

@MainActor
final class WidgetTimelineManager {
private let coverImageManager: CoverImageManager
private var cachedState: WidgetSyncState?
private var debounceTask: Task<Void, Never>?
private let minimumProgressDelta = 5

init(coverImageManager: CoverImageManager = .init()) {
self.coverImageManager = coverImageManager
self.cachedState = coverImageManager.loadWidgetSyncState()
}
}


// MARK: - WidgetTimelineReloader
extension WidgetTimelineManager: WidgetTimelineReloader {
func notifyChapterChange(chapter: Int, progress: Int) {
debounceTask?.cancel()
let targetState = WidgetSyncState(chapter: chapter, progress: progress)
triggerReloadIfNeeded(for: targetState, force: true)
}

func notifyProgressChange(progress: Int) {
debounceTask?.cancel()
debounceTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard
let self,
!Task.isCancelled,
let chapterData = self.coverImageManager.loadCurrentChapterData()
else {
return
}

let targetState = WidgetSyncState(chapter: chapterData.number, progress: progress)
self.triggerReloadIfNeeded(for: targetState)
}
}
}

// MARK: - Private Helpers
private extension WidgetTimelineManager {
func triggerReloadIfNeeded(for targetState: WidgetSyncState, force: Bool = false) {
let shouldReload: Bool

if force {
shouldReload = true
} else if let cachedState {
if cachedState.chapter != targetState.chapter {
shouldReload = true
} else {
let delta = abs(cachedState.progress - targetState.progress)
shouldReload = targetState.progress == 100 || delta >= minimumProgressDelta
}
} else {
shouldReload = true
}

if shouldReload {
cachedState = targetState
coverImageManager.saveWidgetSyncState(targetState)
WidgetCenter.shared.reloadTimelines(ofKind: WIDGET_KIND)
}
}
}
2 changes: 1 addition & 1 deletion DBMultiverseComicKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let package = Package(
targets: ["DBMultiverseComicKit"]),
],
dependencies: [
.package(url: "https://github.com/nikolainobadi/NnTestKit", from: "1.0.0"),
.package(url: "https://github.com/nikolainobadi/NnTestKit", from: "2.0.0"),
.package(url: "https://github.com/nikolainobadi/NnSwiftUIKit.git", from: "3.0.0")
],
targets: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public struct CoverImageManager: Sendable {
private let imageCompressor: ImageCompressing
private let imageFileName = "chapterCoverImage.jpg"
private let jsonFileName = "currentChapterData.json"
private let widgetSyncStateFileName = "widgetSyncState.json"

init(appGroupIdentifier: String, fileSystemManager: FileSystem, imageCompressor: ImageCompressing) {
guard let appGroupDirectory = fileSystemManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else {
Expand Down Expand Up @@ -48,6 +49,28 @@ public extension CoverImageManager {
return nil
}
}

func loadWidgetSyncState() -> WidgetSyncState? {
let fileURL = sharedContainerDirectory.appendingPathComponent(widgetSyncStateFileName)

do {
let data = try fileSystemManager.readData(from: fileURL)
return try JSONDecoder().decode(WidgetSyncState.self, from: data)
} catch {
return nil
}
}

func saveWidgetSyncState(_ state: WidgetSyncState) {
let fileURL = sharedContainerDirectory.appendingPathComponent(widgetSyncStateFileName)

do {
let data = try JSONEncoder().encode(state)
try fileSystemManager.write(data: data, to: fileURL)
} catch {
print("Failed to save widget sync state: \(error)")
}
}
}


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// WidgetSyncState.swift
//
//
// Created by Nikolai Nobadi on 1/11/25.
//

import Foundation

public struct WidgetSyncState: Codable, Equatable, Sendable {
public let chapter: Int
public let progress: Int

public init(chapter: Int, progress: Int) {
self.chapter = chapter
self.progress = progress
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//
// WidgetKind.swift
// DBMultiverseComicKit
//
// Created by Nikolai Nobadi on 12/24/25.
//

public let WIDGET_KIND = "DBMultiverseWidgets"
Loading