├── .gitignore ├── DBMultiverse.xcodeproj ├── project.pbxproj └── project.xcworkspace │ └── contents.xcworkspacedata ├── DBMultiverse ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Resources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ └── newDBM.png │ │ └── Contents.json │ └── DBMultiverse.entitlements └── Sources │ ├── App │ ├── Launch │ │ ├── LaunchView.swift │ │ └── WelcomeView.swift │ └── Start │ │ ├── AppLauncher.swift │ │ ├── DBMultiverseApp.swift │ │ └── TestApp.swift │ ├── MainFeatures │ ├── Adapters │ │ ├── ChapterLoaderAdapter.swift │ │ ├── ComicImageCacheAdapter.swift │ │ └── ComicNetworkingManager.swift │ ├── Comic │ │ ├── ChapterListFeatureView.swift │ │ ├── ComicPageFeatureView.swift │ │ └── ComicPageManager.swift │ ├── Core │ │ ├── DeepLinkNavigationViewModifier.swift │ │ ├── MainFeaturesView.swift │ │ └── MainFeaturesViewModel.swift │ ├── Platforms │ │ ├── iPad │ │ │ ├── iPadComicPageView.swift │ │ │ ├── iPadComicPicker.swift │ │ │ └── iPadMainNavStack.swift │ │ └── iPhone │ │ │ ├── iPhoneComicPageView.swift │ │ │ ├── iPhoneComicPicker.swift │ │ │ └── iPhoneMainTabView.swift │ ├── Settings │ │ ├── Main │ │ │ ├── SettingsFeatureNavStack.swift │ │ │ └── SettingsViewModel.swift │ │ ├── Model │ │ │ ├── CachedChapter.swift │ │ │ └── SettingsLinkItem.swift │ │ └── SubViews │ │ │ ├── CacheChapterListView.swift │ │ │ ├── LanguageSelectionView.swift │ │ │ ├── SettingsDisclaimerView.swift │ │ │ └── SettingsFormView.swift │ └── Shared │ │ ├── Language │ │ ├── ComicLanguage.swift │ │ └── LanguagePicker.swift │ │ ├── Utilities │ │ ├── CustomError.swift │ │ ├── String+Extensions.swift │ │ └── URLFactory.swift │ │ └── Views │ │ └── HapticButton.swift │ ├── Networking │ └── SharedComicNetworkingManager.swift │ ├── SwiftData │ ├── EventHandler │ │ └── SwiftDataChapterListEventHandler.swift │ ├── Model │ │ ├── SwiftDataChapter.swift │ │ └── SwiftDataChapterList.swift │ └── ViewModifiers │ │ ├── PreviewModifiersViewModifier.swift │ │ └── SwiftDataChapterStorageViewModifier.swift │ └── Widgets │ └── WidgetSyncViewModifier.swift ├── DBMultiverseComicKit ├── .gitignore ├── Package.swift ├── Sources │ └── DBMultiverseComicKit │ │ ├── CoverImageCache │ │ ├── CoverImageCache.swift │ │ ├── CoverImageMetaData.swift │ │ └── CurrentChapterData.swift │ │ ├── List │ │ ├── ChapterListView.swift │ │ ├── ChapterRoute.swift │ │ ├── ChapterSection.swift │ │ └── CustomAsyncImage.swift │ │ ├── Model │ │ ├── Chapter.swift │ │ ├── ComicType.swift │ │ └── PageInfo.swift │ │ ├── Page │ │ ├── ComicPage.swift │ │ ├── ComicPageImageView.swift │ │ ├── ComicPageViewModel.swift │ │ └── ZoomableImageView.swift │ │ └── Shared │ │ ├── ComicNavStack.swift │ │ ├── DisclaimerView.swift │ │ ├── DynamicSection.swift │ │ └── LinearGradient+Extensions.swift └── Tests │ └── DBMultiverseComicKitTests │ └── DBMultiverseComicKitTests.swift ├── DBMultiverseParseKit ├── .gitignore ├── Package.swift └── Sources │ └── DBMultiverseParseKit │ ├── ComicHTMLParser.swift │ ├── ComicParseError.swift │ └── ParsedChapter.swift ├── DBMultiverseUnitTests ├── TestPlan │ └── UnitTestPlan.xctestplan └── UnitTests │ ├── ComicPageManagerTests.swift │ ├── MainFeaturesViewModelTests.swift │ └── XCTestCase+Extensions.swift ├── DBMultiverseWidgets ├── Resources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── WidgetBackground.colorset │ │ │ └── Contents.json │ │ └── sampleCoverImage.imageset │ │ │ ├── Contents.json │ │ │ └── dbmSampleImage.png │ ├── DBMultiverseWidgetsExtension.entitlements │ └── Info.plist └── Sources │ ├── Main │ └── DBMultiverseWidgetsBundle.swift │ ├── Widget │ ├── ComicImageEntry.swift │ ├── DBMultiverseWidgets.swift │ └── Provider.swift │ └── WidgetViews │ ├── MediumWidgetView.swift │ └── SmallWidgetView.swift ├── LICENSE ├── README.md ├── docs ├── DBMultiverseComicKit_Documentation.md ├── DBMultiverseParseKit_Documentation.md ├── DBMultiverseWidgets_Documentation.md ├── DBMultiverse_Documentation.md └── XcodeInstallation.md └── media ├── appIcon.jpeg ├── ipad_chapterList.png ├── ipad_comicView.png ├── iphone_chapterList.png └── iphone_comicView.png /.gitignore: -------------------------------------------------------------------------------- 1 | ## User settings 2 | xcuserdata/ 3 | xcshareddata/ 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | # Swift Package Manager 10 | .build/ 11 | .swiftpm/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | ## Firebase 17 | firestore-debug.log 18 | 19 | ## CIPipeKit 20 | CIPipeKit/TestResults 21 | 22 | # Directories potentially created on remote AFP share 23 | node_modules/ 24 | serviceKey/ 25 | otherFiles/ -------------------------------------------------------------------------------- /DBMultiverse.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DBMultiverse/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /DBMultiverse/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /DBMultiverse/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "newDBM.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /DBMultiverse/Resources/Assets.xcassets/AppIcon.appiconset/newDBM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolainobadi/DBMultiverse/3cc87a1e6f310334bfbdda4f720edf71e1ef3366/DBMultiverse/Resources/Assets.xcassets/AppIcon.appiconset/newDBM.png -------------------------------------------------------------------------------- /DBMultiverse/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /DBMultiverse/Resources/DBMultiverse.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.com.nobadi.dbm 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/App/Launch/LaunchView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/6/25. 6 | // 7 | 8 | import SwiftUI 9 | import NnSwiftUIKit 10 | 11 | struct LaunchView: View { 12 | @AppStorage("isInitialLogin") private var isInitialLogin = true 13 | @AppStorage("selectedLanguage") private var language: ComicLanguage = .english 14 | 15 | var body: some View { 16 | MainFeaturesView(viewModel: .init(loader: ChapterLoaderAdapter()), language: $language) 17 | .showingConditionalView(when: isInitialLogin) { 18 | WelcomeView(language: $language) { 19 | isInitialLogin = false 20 | } 21 | } 22 | } 23 | } 24 | 25 | 26 | // MARK: - Preview 27 | #Preview { 28 | LaunchView() 29 | .withPreviewModifiers() 30 | } 31 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/App/Launch/WelcomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WelcomeView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/6/25. 6 | // 7 | 8 | import SwiftUI 9 | import NnSwiftUIKit 10 | import DBMultiverseComicKit 11 | 12 | struct WelcomeView: View { 13 | @Binding var language: ComicLanguage 14 | @State private var selectingLanguage = false 15 | 16 | let getStarted: () -> Void 17 | 18 | var body: some View { 19 | VStack { 20 | WelcomeHeaderView() 21 | 22 | Spacer() 23 | 24 | DisclaimerView() 25 | .showingConditionalView(when: selectingLanguage) { 26 | VStack { 27 | Text("Choose a language") 28 | .padding() 29 | .withFont() 30 | 31 | LanguagePicker(selection: $language) 32 | } 33 | } 34 | 35 | Spacer() 36 | 37 | VStack { 38 | Button("Select Language") { 39 | selectingLanguage = true 40 | } 41 | .withFont() 42 | .showingConditionalView(when: selectingLanguage) { 43 | Button("Get Started", action: getStarted) 44 | .padding() 45 | .withFont() 46 | } 47 | .buttonStyle(.borderedProminent) 48 | } 49 | .padding() 50 | } 51 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) 52 | } 53 | } 54 | 55 | 56 | // MARK: - Header 57 | fileprivate struct WelcomeHeaderView: View { 58 | var body: some View { 59 | VStack { 60 | Text("Welcome to") 61 | .bold() 62 | 63 | HStack { 64 | Text("Multiverse") 65 | .textLinearGradient(.yellowText) 66 | 67 | Text("Reader") 68 | .textLinearGradient(.redText) 69 | } 70 | .padding(.horizontal) 71 | .withFont(.title3, autoSizeLineLimit: 1) 72 | 73 | Text("for iOS") 74 | .bold() 75 | } 76 | } 77 | } 78 | 79 | 80 | // MARK: - Preview 81 | #Preview { 82 | WelcomeView(language: .constant(.english), getStarted: { }) 83 | } 84 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/App/Start/AppLauncher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppLauncher.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 12/24/24. 6 | // 7 | 8 | import Foundation 9 | import NnTestVariables 10 | 11 | @main 12 | struct AppLauncher { 13 | static func main() throws { 14 | if ProcessInfo.isTesting { 15 | TestApp.main() 16 | } else { 17 | DBMultiverseApp.main() 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/App/Start/DBMultiverseApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DBMultiverseApp.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 11/9/24. 6 | // 7 | 8 | import SwiftUI 9 | import NnSwiftUIKit 10 | import NnSwiftDataKit 11 | 12 | struct DBMultiverseApp: App { 13 | var body: some Scene { 14 | WindowGroup { 15 | LaunchView() 16 | .syncWidgetData() 17 | .withNnLoadingView() 18 | .withNnErrorHandling() 19 | .preferredColorScheme(.dark) 20 | } 21 | .initializeSwiftDataModelContainer(schema: .init([SwiftDataChapter.self])) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/App/Start/TestApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestApp.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 12/24/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TestApp: App { 11 | var body: some Scene { 12 | WindowGroup { 13 | Text("Running unit tests...") 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Adapters/ChapterLoaderAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterLoaderAdapter.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 11/11/24. 6 | // 7 | 8 | import Foundation 9 | import DBMultiverseComicKit 10 | import DBMultiverseParseKit 11 | 12 | final class ChapterLoaderAdapter: ChapterLoader { 13 | func loadChapters(url: URL?) async throws -> [Chapter] { 14 | let data = try await SharedComicNetworkingManager.fetchData(from: url) 15 | 16 | return try ComicHTMLParser.parseChapterList(data: data).map({ $0.toChapter() }) 17 | } 18 | } 19 | 20 | 21 | // MARK: - Extension Dependencies 22 | extension ParsedChapter { 23 | func toChapter() -> Chapter { 24 | return .init( 25 | name: name, 26 | number: number, 27 | startPage: startPage, 28 | endPage: endPage, 29 | universe: universe, 30 | lastReadPage: nil, 31 | coverImageURL: coverImageURL, 32 | didFinishReading: false 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Adapters/ComicImageCacheAdapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicImageCacheAdapter.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/9/25. 6 | // 7 | 8 | import Foundation 9 | import DBMultiverseComicKit 10 | 11 | /// An adapter that implements the `ComicImageCache` protocol, providing functionality for caching comic images, managing chapter progress, and interacting with the filesystem. 12 | final class ComicImageCacheAdapter { 13 | /// The type of comic (e.g., story or specials) for which the adapter is responsible. 14 | private let comicType: ComicType 15 | 16 | /// The file manager instance used for accessing and modifying the file system. 17 | private let fileManager: FileManager 18 | 19 | /// The main features view model used for updating page progress. 20 | private let viewModel: MainFeaturesViewModel 21 | 22 | /// A shared cache for storing cover images and progress metadata. 23 | private let coverImageCache: CoverImageCache 24 | 25 | /// Initializes the `ComicImageCacheAdapter` with its dependencies. 26 | /// - Parameters: 27 | /// - comicType: The type of comic (story or specials). 28 | /// - viewModel: The main features view model for managing chapter progress. 29 | /// - fileManager: The file manager instance for file operations. Defaults to `.default`. 30 | /// - coverImageCache: The shared cache for cover images. Defaults to `.shared`. 31 | init(comicType: ComicType, viewModel: MainFeaturesViewModel, fileManager: FileManager = .default, coverImageCache: CoverImageCache = .shared) { 32 | self.comicType = comicType 33 | self.viewModel = viewModel 34 | self.fileManager = fileManager 35 | self.coverImageCache = coverImageCache 36 | } 37 | } 38 | 39 | // MARK: - Cache 40 | extension ComicImageCacheAdapter: ComicImageCache { 41 | /// Updates the current page number and read progress in the cache. 42 | /// - Parameters: 43 | /// - pageNumber: The current page number being read. 44 | /// - readProgress: The read progress as a percentage. 45 | func updateCurrentPageNumber(_ pageNumber: Int, readProgress: Int) { 46 | coverImageCache.updateProgress(to: readProgress) 47 | 48 | DispatchQueue.main.async { [unowned self] in 49 | viewModel.updateCurrentPageNumber(pageNumber, comicType: comicType) 50 | } 51 | } 52 | 53 | /// Saves a chapter cover image and its metadata to the cache. 54 | /// - Parameters: 55 | /// - imageData: The image data of the cover. 56 | /// - metadata: The metadata associated with the cover image. 57 | /// - Throws: An error if the image data cannot be saved. 58 | func saveChapterCoverImage(imageData: Data, metadata: CoverImageMetaData) throws { 59 | coverImageCache.saveCurrentChapterData(imageData: imageData, metadata: metadata) 60 | } 61 | 62 | /// Loads a cached image for a specific chapter and page. 63 | /// - Parameters: 64 | /// - chapter: The chapter number. 65 | /// - page: The page number. 66 | /// - Returns: The cached `PageInfo` object if available. 67 | /// - Throws: An error if the image cannot be loaded. 68 | func loadCachedImage(chapter: Int, page: Int) throws -> PageInfo? { 69 | let singlePagePath = getCacheDirectory(for: chapter, page: page) 70 | 71 | if let data = fileManager.contents(atPath: singlePagePath.path) { 72 | return PageInfo(chapter: chapter, pageNumber: page, secondPageNumber: nil, imageData: data) 73 | } 74 | 75 | let chapterFolder = singlePagePath.deletingLastPathComponent() 76 | let metadataFile = chapterFolder.appendingPathComponent("metadata.json") 77 | 78 | if let metadataData = fileManager.contents(atPath: metadataFile.path), 79 | let metadata = try? JSONSerialization.jsonObject(with: metadataData, options: []) as? [String: Any], 80 | let pages = metadata["pages"] as? [[String: Any]], 81 | let pageEntry = pages.first(where: { $0["pageNumber"] as? Int == page }), 82 | let fileName = pageEntry["fileName"] as? String, 83 | let secondPageNumber = pageEntry["secondPageNumber"] as? Int { 84 | 85 | let twoPagePath = chapterFolder.appendingPathComponent(fileName) 86 | if let data = fileManager.contents(atPath: twoPagePath.path) { 87 | return PageInfo(chapter: chapter, pageNumber: page, secondPageNumber: secondPageNumber, imageData: data) 88 | } 89 | } 90 | 91 | return nil 92 | } 93 | 94 | /// Saves a page image and its metadata to the cache. 95 | /// - Parameter pageInfo: The `PageInfo` object containing image data and metadata. 96 | /// - Throws: An error if the image data or metadata cannot be saved. 97 | func savePageImage(pageInfo: PageInfo) throws { 98 | let filePath = getCacheDirectory(for: pageInfo.chapter, page: pageInfo.pageNumber, secondPageNumber: pageInfo.secondPageNumber) 99 | let chapterFolder = filePath.deletingLastPathComponent() 100 | try fileManager.createDirectory(at: chapterFolder, withIntermediateDirectories: true) 101 | 102 | try pageInfo.imageData.write(to: filePath) 103 | 104 | if let secondPageNumber = pageInfo.secondPageNumber { 105 | let metadataFile = chapterFolder.appendingPathComponent("metadata.json") 106 | var metadata: [String: Any] = [:] 107 | 108 | if let existingData = fileManager.contents(atPath: metadataFile.path), 109 | let existingMetadata = try? JSONSerialization.jsonObject(with: existingData, options: []) as? [String: Any] { 110 | metadata = existingMetadata 111 | } 112 | 113 | var pages = metadata["pages"] as? [[String: Any]] ?? [] 114 | let pageEntry: [String: Any] = [ 115 | "pageNumber": pageInfo.pageNumber, 116 | "secondPageNumber": secondPageNumber, 117 | "fileName": "Page_\(pageInfo.pageNumber)-\(secondPageNumber).jpg" 118 | ] 119 | 120 | pages.append(pageEntry) 121 | metadata["pages"] = pages 122 | 123 | let updatedData = try JSONSerialization.data(withJSONObject: metadata, options: [.prettyPrinted]) 124 | try updatedData.write(to: metadataFile) 125 | } 126 | } 127 | } 128 | 129 | // MARK: - Private Methods 130 | private extension ComicImageCacheAdapter { 131 | /// Constructs the file path for caching a page image. 132 | /// - Parameters: 133 | /// - chapter: The chapter number. 134 | /// - page: The page number. 135 | /// - secondPageNumber: The second page number if applicable. 136 | /// - Returns: A `URL` representing the file path in the cache directory. 137 | func getCacheDirectory(for chapter: Int, page: Int, secondPageNumber: Int? = nil) -> URL { 138 | let cacheDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! 139 | let fileName = secondPageNumber != nil ? "Page_\(page)-\(secondPageNumber!).jpg" : "Page_\(page).jpg" 140 | 141 | return cacheDirectory.appendingPathComponent("Chapters/Chapter_\(chapter)/\(fileName)") 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Adapters/ComicNetworkingManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicPageNetworkServiceAdapter.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/9/25. 6 | // 7 | 8 | import Foundation 9 | import DBMultiverseParseKit 10 | 11 | final class ComicPageNetworkServiceAdapter: ComicPageNetworkService { 12 | func fetchImageData(from url: URL?) async throws -> Data { 13 | let networker = SharedComicNetworkingManager.self 14 | let data = try await networker.fetchData(from: url) 15 | let imgSrc = try ComicHTMLParser.parseComicPageImageSource(data: data) 16 | 17 | return try await networker.fetchData(from: .init(string: .makeFullURLString(suffix: imgSrc))) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Comic/ChapterListFeatureView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterListFeatureView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | struct ChapterListFeatureView: View { 12 | let eventHandler: SwiftDataChapterListEventHandler 13 | 14 | private var imageSize: CGSize { 15 | return .init(width: getWidthPercent(15), height: getHeightPercent(isPad ? 15 : 10)) 16 | } 17 | 18 | var body: some View { 19 | ChapterListView(imageSize: imageSize, eventHandler: eventHandler) { selection in 20 | iPhoneComicPicker(selection: selection) 21 | .showingConditionalView(when: isPad) { 22 | iPadComicPicker(selection: selection) 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Comic/ComicPageFeatureView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicPageFeatureView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | struct ComicPageFeatureView: View { 12 | @Environment(\.dismiss) private var dismiss 13 | @StateObject var viewModel: ComicPageViewModel 14 | 15 | var body: some View { 16 | Text("Loading page...") 17 | .withFont() 18 | .showingViewWithOptional(viewModel.currentPage) { page in 19 | ComicPageContentView( 20 | page: page, 21 | nextPage: viewModel.nextPage, 22 | previousPage: viewModel.previousPage, 23 | finishChapter: { dismiss() } 24 | ) 25 | } 26 | .asyncTask { 27 | try await viewModel.loadData() 28 | } 29 | 30 | } 31 | } 32 | 33 | // MARK: - Content 34 | fileprivate struct ComicPageContentView: View { 35 | let page: ComicPage 36 | let nextPage: () -> Void 37 | let previousPage: () -> Void 38 | let finishChapter: () -> Void 39 | 40 | var body: some View { 41 | iPhoneComicPageView(page: page, nextPage: nextPage, previousPage: previousPage, finishChapter: finishChapter) 42 | .showingConditionalView(when: isPad) { 43 | iPadComicPageView(page: page, nextPage: nextPage, previousPage: previousPage, finishChapter: finishChapter) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Comic/ComicPageManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicPageManager.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/9/25. 6 | // 7 | 8 | import Foundation 9 | import DBMultiverseComicKit 10 | 11 | /// Manages comic pages, handling caching, fetching, and progress updates for a specific chapter. 12 | final class ComicPageManager { 13 | /// The current chapter being managed. 14 | private let chapter: Chapter 15 | 16 | /// The language of the comic being managed. 17 | private let language: ComicLanguage 18 | 19 | /// Handles caching of comic images. 20 | private let imageCache: ComicImageCache 21 | 22 | /// Handles network requests for fetching comic pages. 23 | private let networkService: ComicPageNetworkService 24 | 25 | /// Handles chapter progress updates such as marking chapters as read. 26 | private let chapterProgressHandler: ChapterProgressHandler 27 | 28 | /// Initializes the comic page manager with its dependencies. 29 | /// - Parameters: 30 | /// - chapter: The chapter to be managed. 31 | /// - language: The language of the comic. 32 | /// - imageCache: A cache for storing and retrieving comic images. 33 | /// - networkService: A service for fetching comic pages from a network. 34 | /// - chapterProgressHandler: A handler for updating chapter progress. 35 | init(chapter: Chapter, language: ComicLanguage, imageCache: ComicImageCache, networkService: ComicPageNetworkService, chapterProgressHandler: ChapterProgressHandler) { 36 | self.chapter = chapter 37 | self.language = language 38 | self.imageCache = imageCache 39 | self.networkService = networkService 40 | self.chapterProgressHandler = chapterProgressHandler 41 | } 42 | } 43 | 44 | // MARK: - Delegate 45 | extension ComicPageManager: ComicPageDelegate { 46 | /// Saves the chapter cover page to the cache with associated metadata. 47 | /// - Parameter info: The `PageInfo` containing image data and page details. 48 | func saveChapterCoverPage(_ info: PageInfo) { 49 | let readProgress = calculateProgress(page: info.pageNumber) 50 | let metadata = CoverImageMetaData(chapterName: chapter.name, chapterNumber: chapter.number, readProgress: readProgress) 51 | 52 | try? imageCache.saveChapterCoverImage(imageData: info.imageData, metadata: metadata) 53 | } 54 | 55 | /// Updates the last read page number and caches the progress. 56 | /// - Parameter pageNumber: The page number to update as the last read. 57 | func updateCurrentPageNumber(_ pageNumber: Int) { 58 | updateChapterProgress(lastReadPage: pageNumber) 59 | imageCache.updateCurrentPageNumber(pageNumber, readProgress: calculateProgress(page: pageNumber)) 60 | } 61 | 62 | /// Loads a list of comic pages, either from the cache or by fetching them from the network. 63 | /// - Parameter pages: The list of page numbers to load. 64 | /// - Returns: An array of `PageInfo` for the requested pages. 65 | /// - Throws: An error if the pages cannot be loaded or cached. 66 | func loadPages(_ pages: [Int]) async throws -> [PageInfo] { 67 | var infoList = [PageInfo]() 68 | 69 | // TODO: - Handle thrown errors properly 70 | for page in pages { 71 | if !page.isSecondPage { 72 | if let cachedInfo = try? imageCache.loadCachedImage(chapter: chapter.number, page: page) { 73 | infoList.append(cachedInfo) 74 | } else if let fetchedInfo = await fetchPageInfo(page: page) { 75 | infoList.append(fetchedInfo) 76 | try? imageCache.savePageImage(pageInfo: fetchedInfo) 77 | } 78 | } 79 | } 80 | 81 | return infoList 82 | } 83 | } 84 | 85 | // MARK: - Private Methods 86 | private extension ComicPageManager { 87 | /// Updates the chapter progress based on the last read page. 88 | /// - Parameter lastReadPage: The last page number read in the chapter. 89 | func updateChapterProgress(lastReadPage: Int) { 90 | chapterProgressHandler.updateLastReadPage(page: lastReadPage, chapter: chapter) 91 | 92 | if chapter.endPage == lastReadPage { 93 | chapterProgressHandler.markChapterAsRead(chapter) 94 | } 95 | } 96 | 97 | /// Calculates the read progress of the chapter as a percentage. 98 | /// - Parameter page: The page number currently being read. 99 | /// - Returns: The progress as a percentage (0–100). 100 | func calculateProgress(page: Int) -> Int { 101 | let totalPages = chapter.endPage - chapter.startPage + 1 102 | let pagesRead = page - chapter.startPage + 1 103 | 104 | return max(0, min((pagesRead * 100) / totalPages, 100)) 105 | } 106 | 107 | /// Fetches information about a specific comic page from the network. 108 | /// - Parameter page: The page number to fetch. 109 | /// - Returns: A `PageInfo` object if the page is successfully fetched; otherwise, `nil`. 110 | func fetchPageInfo(page: Int) async -> PageInfo? { 111 | do { 112 | let url = URLFactory.makeURL(language: language, pathComponent: .comicPage(page)) 113 | let imageData = try await networkService.fetchImageData(from: url) 114 | 115 | return .init(chapter: chapter.number, pageNumber: page, secondPageNumber: page.secondPage, imageData: imageData) 116 | } catch { 117 | return nil 118 | } 119 | } 120 | } 121 | 122 | // MARK: - Dependencies 123 | /// Protocol defining a service for fetching comic page data from the network. 124 | protocol ComicPageNetworkService { 125 | /// Fetches image data from a given URL. 126 | /// - Parameter url: The URL to fetch image data from. 127 | /// - Returns: The image data fetched from the network. 128 | /// - Throws: An error if the data cannot be fetched. 129 | func fetchImageData(from url: URL?) async throws -> Data 130 | } 131 | 132 | /// Protocol defining methods for managing chapter progress. 133 | protocol ChapterProgressHandler { 134 | /// Marks the given chapter as read. 135 | /// - Parameter chapter: The chapter to mark as read. 136 | func markChapterAsRead(_ chapter: Chapter) 137 | 138 | /// Updates the last read page for the given chapter. 139 | /// - Parameters: 140 | /// - page: The last page number read. 141 | /// - chapter: The chapter to update progress for. 142 | func updateLastReadPage(page: Int, chapter: Chapter) 143 | } 144 | 145 | /// Protocol defining a cache for comic images. 146 | protocol ComicImageCache { 147 | /// Saves the given page image data to the cache. 148 | /// - Parameter pageInfo: The `PageInfo` to save. 149 | /// - Throws: An error if the image cannot be saved. 150 | func savePageImage(pageInfo: PageInfo) throws 151 | 152 | /// Loads cached image data for a specific chapter and page. 153 | /// - Parameters: 154 | /// - chapter: The chapter number. 155 | /// - page: The page number. 156 | /// - Returns: The cached `PageInfo` if available. 157 | /// - Throws: An error if the image cannot be loaded. 158 | func loadCachedImage(chapter: Int, page: Int) throws -> PageInfo? 159 | 160 | /// Updates the current page number and read progress in the cache. 161 | /// - Parameters: 162 | /// - pageNumber: The current page number. 163 | /// - readProgress: The read progress as a percentage. 164 | func updateCurrentPageNumber(_ pageNumber: Int, readProgress: Int) 165 | 166 | /// Saves the chapter cover image with associated metadata. 167 | /// - Parameters: 168 | /// - imageData: The image data to save. 169 | /// - metadata: The metadata describing the cover image. 170 | /// - Throws: An error if the image cannot be saved. 171 | func saveChapterCoverImage(imageData: Data, metadata: CoverImageMetaData) throws 172 | } 173 | 174 | // MARK: - Extension Dependencies 175 | /// There are 2 instances where the comic displays 2 pages at a time. This handles those scenarios 176 | fileprivate extension Int { 177 | /// Determines if the page is a second page (e.g., page 9 or 21). 178 | var isSecondPage: Bool { 179 | return self == 9 || self == 21 180 | } 181 | 182 | /// Returns the associated second page number if applicable. 183 | var secondPage: Int? { 184 | return self == 8 ? 9 : self == 20 ? 21 : nil 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Core/DeepLinkNavigationViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeepLinkNavigationViewModifier.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | struct DeepLinkNavigationViewModifier: ViewModifier { 12 | @Binding var path: NavigationPath 13 | 14 | let chapters: [Chapter] 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .onOpenURL { url in 19 | if let chapterNumber = Int(url.lastPathComponent) { 20 | if let chapter = chapters.first(where: { $0.number == chapterNumber }) { 21 | path.append(ChapterRoute(chapter: chapter, comicType: chapter.universe == nil ? .story : .specials)) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | 28 | extension View { 29 | /// Adds deep link navigation to a view, allowing navigation to specific chapters based on URLs. 30 | /// - Parameters: 31 | /// - path: A binding to the `NavigationPath` for managing the navigation stack. 32 | /// - chapters: An array of chapters to use for resolving deep link URLs. 33 | func withDeepLinkNavigation(path: Binding, chapters: [Chapter]) -> some View { 34 | modifier(DeepLinkNavigationViewModifier(path: path, chapters: chapters)) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Core/MainFeaturesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainFeaturesView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 11/30/24. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftData 10 | import DBMultiverseComicKit 11 | 12 | struct MainFeaturesView: View { 13 | @State private var path: NavigationPath = .init() 14 | @StateObject var viewModel: MainFeaturesViewModel 15 | @Query(sort: \SwiftDataChapter.number, order: .forward) var chapterList: SwiftDataChapterList 16 | 17 | @Binding var language: ComicLanguage 18 | 19 | var body: some View { 20 | MainNavStack(path: $path) { 21 | ChapterListFeatureView(eventHandler: .customInit(viewModel: viewModel, chapterList: chapterList)) 22 | .navigationDestination(for: ChapterRoute.self) { route in 23 | ComicPageFeatureView( 24 | viewModel: .customInit(route: route, store: viewModel, chapterList: chapterList, language: language) 25 | ) 26 | } 27 | } settingsContent: { 28 | SettingsFeatureNavStack(language: $language, viewModel: .init(), canDismiss: isPad) 29 | } 30 | .asyncTask { 31 | try await viewModel.loadData(language: language) 32 | } 33 | .asyncOnChange(of: language) { newLanguage in 34 | try await viewModel.loadData(language: newLanguage) 35 | } 36 | .syncChaptersWithSwiftData(chapters: viewModel.chapters) 37 | .withDeepLinkNavigation(path: $path, chapters: chapterList.chapters) 38 | .onChange(of: viewModel.nextChapterToRead) { _, newValue in 39 | if let newValue { 40 | // TODO: - only works for main story chapters for now 41 | path.append(ChapterRoute(chapter: newValue, comicType: .story)) 42 | } 43 | } 44 | } 45 | } 46 | 47 | 48 | // MARK: - NavStack 49 | fileprivate struct MainNavStack: View { 50 | @Binding var path: NavigationPath 51 | @ViewBuilder var comicContent: () -> ComicContent 52 | @ViewBuilder var settingsContent: () -> SettingsContent 53 | 54 | var body: some View { 55 | iPhoneMainTabView(path: $path, comicContent: comicContent, settingsTab: settingsContent) 56 | .showingConditionalView(when: isPad) { 57 | iPadMainNavStack(path: $path, comicContent: comicContent, settingsContent: settingsContent) 58 | } 59 | } 60 | } 61 | 62 | 63 | // MARK: - Preview 64 | #Preview { 65 | class PreviewLoader: ChapterLoader { 66 | func loadChapters(url: URL?) async throws -> [Chapter] { [] } 67 | } 68 | 69 | return MainFeaturesView(viewModel: .init(loader: PreviewLoader()), language: .constant(.english)) 70 | .withPreviewModifiers() 71 | } 72 | 73 | 74 | // MARK: - Extension Dependencies 75 | fileprivate extension SwiftDataChapterListEventHandler { 76 | static func customInit(viewModel: MainFeaturesViewModel, chapterList: SwiftDataChapterList) -> SwiftDataChapterListEventHandler { 77 | return .init( 78 | lastReadSpecialPage: viewModel.lastReadSpecialPage, 79 | lastReadMainStoryPage: viewModel.lastReadMainStoryPage, 80 | chapterList: chapterList, 81 | onStartNextChapter: viewModel.startNextChapter(_:) 82 | ) 83 | } 84 | } 85 | 86 | fileprivate extension ComicPageViewModel { 87 | static func customInit(route: ChapterRoute, store: MainFeaturesViewModel, chapterList: SwiftDataChapterList, language: ComicLanguage) -> ComicPageViewModel { 88 | let currentPageNumber = store.getCurrentPageNumber(for: route.comicType) 89 | let imageCache = ComicImageCacheAdapter(comicType: route.comicType, viewModel: store) 90 | let networkService = ComicPageNetworkServiceAdapter() 91 | let manager = ComicPageManager( 92 | chapter: route.chapter, 93 | language: language, 94 | imageCache: imageCache, 95 | networkService: networkService, 96 | chapterProgressHandler: chapterList 97 | ) 98 | 99 | return .init(chapter: route.chapter, currentPageNumber: currentPageNumber, delegate: manager) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Core/MainFeaturesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainFeaturesViewModel.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | /// A view model responsible for managing the main features of the app, such as handling chapters, page tracking, and user preferences. 12 | final class MainFeaturesViewModel: ObservableObject { 13 | @Published var chapters: [Chapter] = [] 14 | @Published var nextChapterToRead: Chapter? 15 | @AppStorage var lastReadSpecialPage: Int 16 | @AppStorage var lastReadMainStoryPage: Int 17 | 18 | private let loader: ChapterLoader 19 | 20 | /// Initializes the `MainFeaturesViewModel`. 21 | /// - Parameters: 22 | /// - loader: A dependency responsible for fetching chapter data. 23 | /// - userDefaults: The `UserDefaults` instance to store and retrieve page tracking data. Defaults to `.standard`. 24 | init(loader: ChapterLoader, userDefaults: UserDefaults? = .standard) { 25 | self.loader = loader 26 | 27 | // Initialize AppStorage properties with custom or standard UserDefaults. 28 | self._lastReadSpecialPage = .init(wrappedValue: 168, .lastReadSpecialPage, store: userDefaults) 29 | self._lastReadMainStoryPage = .init(wrappedValue: 0, .lastReadMainStoryPage, store: userDefaults) 30 | } 31 | } 32 | 33 | // MARK: - Actions 34 | extension MainFeaturesViewModel { 35 | /// Loads chapter data for a specific language asynchronously. 36 | /// - Parameter language: The language of the comics to fetch. 37 | /// - Throws: An error if the chapter data cannot be fetched. 38 | func loadData(language: ComicLanguage) async throws { 39 | // Construct the URL for fetching chapters. 40 | let url = URLFactory.makeURL(language: language, pathComponent: .chapterList) 41 | 42 | // Fetch chapters using the loader and set them to the published property. 43 | let fetchedList = try await loader.loadChapters(url: url) 44 | await setChapters(fetchedList) 45 | } 46 | 47 | /// Updates the last read page number for a given comic type. 48 | /// - Parameters: 49 | /// - pageNumber: The page number to update. 50 | /// - comicType: The type of comic (e.g., story or specials). 51 | func updateCurrentPageNumber(_ pageNumber: Int, comicType: ComicType) { 52 | switch comicType { 53 | case .story: 54 | lastReadMainStoryPage = pageNumber 55 | case .specials: 56 | lastReadSpecialPage = pageNumber 57 | } 58 | } 59 | 60 | /// Retrieves the current page number for a given comic type. 61 | /// - Parameter type: The type of comic (e.g., story or specials). 62 | /// - Returns: The last read page number for the specified comic type. 63 | func getCurrentPageNumber(for type: ComicType) -> Int { 64 | switch type { 65 | case .story: 66 | return lastReadMainStoryPage 67 | case .specials: 68 | return lastReadSpecialPage 69 | } 70 | } 71 | 72 | /// Sets the next chapter to read for the user. 73 | /// - Parameter chapter: The chapter to mark as next. 74 | func startNextChapter(_ chapter: Chapter) { 75 | nextChapterToRead = chapter 76 | } 77 | } 78 | 79 | // MARK: - MainActor 80 | @MainActor 81 | private extension MainFeaturesViewModel { 82 | /// Updates the list of chapters on the main actor to ensure thread safety. 83 | /// - Parameter chapters: The chapters to set. 84 | func setChapters(_ chapters: [Chapter]) { 85 | self.chapters = chapters 86 | } 87 | } 88 | 89 | // MARK: - Dependencies 90 | /// Protocol defining the requirements for loading chapter data. 91 | protocol ChapterLoader { 92 | /// Loads chapters from a specified URL. 93 | /// - Parameter url: The URL to load chapter data from. 94 | /// - Returns: An array of `Chapter` objects. 95 | /// - Throws: An error if the data cannot be fetched or decoded. 96 | func loadChapters(url: URL?) async throws -> [Chapter] 97 | } 98 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Platforms/iPad/iPadComicPageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iPadComicPageView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | struct iPadComicPageView: View { 12 | let page: ComicPage 13 | let nextPage: () -> Void 14 | let previousPage: () -> Void 15 | let finishChapter: () -> Void 16 | 17 | var body: some View { 18 | VStack { 19 | HStack { 20 | iPadComicButton(.previous, disabled: page.isFirstPage, action: previousPage) 21 | Spacer() 22 | ComicPageImageView(page: page) 23 | Spacer() 24 | iPadComicButton(.next, disabled: page.isLastPage, action: nextPage) 25 | } 26 | 27 | Button("Finish Chapter", action: finishChapter) 28 | .tint(.red) 29 | .buttonStyle(.borderedProminent) 30 | .onlyShow(when: page.isLastPage) 31 | } 32 | } 33 | } 34 | 35 | 36 | // MARK: - Button 37 | fileprivate struct iPadComicButton: View { 38 | let type: iPadComicButtonType 39 | let disabled: Bool 40 | let action: () -> Void 41 | 42 | init(_ type: iPadComicButtonType, disabled: Bool, action: @escaping () -> Void) { 43 | self.type = type 44 | self.action = action 45 | self.disabled = disabled 46 | } 47 | 48 | var body: some View { 49 | Button(action: action) { 50 | Image(systemName: type.imageName) 51 | .bold() 52 | .padding() 53 | .withFont(textColor: type.tint) 54 | .frame(maxHeight: .infinity) 55 | .background(.gray.opacity(0.5)) 56 | .clipShape(.rect(cornerRadius: 10)) 57 | } 58 | .disabled(disabled) 59 | .opacity(disabled ? 0.2 : 1) 60 | } 61 | } 62 | 63 | 64 | // MARK: - Dependencies 65 | enum iPadComicButtonType { 66 | case next, previous 67 | 68 | var imageName: String { 69 | switch self { 70 | case .next: 71 | return "chevron.right" 72 | case .previous: 73 | return "chevron.left" 74 | } 75 | } 76 | 77 | var tint: Color { 78 | switch self { 79 | case .next: 80 | return .blue 81 | case .previous: 82 | return .red 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Platforms/iPad/iPadComicPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iPadComicPicker.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/4/25. 6 | // 7 | 8 | import SwiftUI 9 | import NnSwiftUIKit 10 | import DBMultiverseComicKit 11 | 12 | struct iPadComicPicker: View { 13 | @Namespace private var namespace 14 | @Binding var selection: ComicType 15 | 16 | var body: some View { 17 | HStack { 18 | ForEach(ComicType.allCases) { type in 19 | ComitTypeButton(type, selection: $selection, namespace: namespace) 20 | .padding(.horizontal) 21 | } 22 | } 23 | .frame(maxHeight: getHeightPercent(5)) 24 | .animation(.easeInOut, value: selection) 25 | } 26 | } 27 | 28 | 29 | // MARK: - Button 30 | fileprivate struct ComitTypeButton: View { 31 | @Binding var selection: ComicType 32 | @State private var offset: CGFloat = 0 33 | @State private var rotation: CGFloat = 0 34 | 35 | let type: ComicType 36 | let namespace: Namespace.ID 37 | 38 | private var isSelected: Bool { 39 | selection == type 40 | } 41 | 42 | init(_ type: ComicType, selection: Binding, namespace: Namespace.ID) { 43 | self.type = type 44 | self._selection = selection 45 | self.namespace = namespace 46 | } 47 | 48 | var body: some View { 49 | Button(action: { selection = type }) { 50 | ZStack { 51 | Capsule() 52 | .fill(type.color.opacity(0.2)) 53 | .frame(maxWidth: getWidthPercent(20)) 54 | .matchedGeometryEffect(id: "picker", in: namespace) 55 | .onlyShow(when: isSelected) 56 | 57 | HStack(spacing: 10) { 58 | Image(systemName: type.icon) 59 | .font(.system(size: 20, weight: .semibold, design: .rounded)) 60 | .foregroundColor(isSelected ? type.color : .black.opacity(0.6)) 61 | .rotationEffect(.degrees(rotation)) 62 | .scaleEffect(isSelected ? 1 : 0.9) 63 | .animation(.easeInOut, value: rotation) 64 | .opacity(isSelected ? 1 : 0.7) 65 | .offset(y: offset) 66 | .animation(.default, value: offset) 67 | 68 | Text(type.title) 69 | .font(.system(size: 20, weight: .semibold, design: .rounded)) 70 | .foregroundColor(isSelected ? type.color : .gray) 71 | .padding(.trailing, 20) 72 | } 73 | .padding(.vertical, 10) 74 | } 75 | } 76 | .buttonStyle(.plain) 77 | .onChange(of: selection) { _, newValue in 78 | if newValue == type { 79 | offset = -60 80 | rotation += (type.id < newValue.id) ? 360 : -360 81 | 82 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 83 | offset = 0 84 | rotation += (type.id < newValue.id) ? 720 : -720 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Platforms/iPad/iPadMainNavStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iPadMainNavStack.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | struct iPadMainNavStack: View { 12 | @Binding var path: NavigationPath 13 | @State private var showingSettings = false 14 | @ViewBuilder var comicContent: () -> ComicContent 15 | @ViewBuilder var settingsContent: () -> SettingsContent 16 | 17 | var body: some View { 18 | ComicNavStack(path: $path) { 19 | comicContent() 20 | .withNavBarButton(buttonContent: .image(.system("gearshape"))) { 21 | showingSettings = true 22 | } 23 | .sheetWithErrorHandling(isPresented: $showingSettings) { 24 | settingsContent() 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Platforms/iPhone/iPhoneComicPageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iPhoneComicPageView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import NnSwiftUIKit 10 | import DBMultiverseComicKit 11 | 12 | struct iPhoneComicPageView: View { 13 | let page: ComicPage 14 | let nextPage: () -> Void 15 | let previousPage: () -> Void 16 | let finishChapter: () -> Void 17 | 18 | var body: some View { 19 | VStack { 20 | ComicPageImageView(page: page) 21 | 22 | HStack { 23 | HapticButton("Previous", action: previousPage) 24 | .tint(.red) 25 | .disabled(page.isFirstPage) 26 | 27 | Spacer() 28 | 29 | HapticButton("Next", action: nextPage) 30 | .tint(.blue) 31 | .showingConditionalView(when: page.isLastPage) { 32 | HapticButton("Finish Chapter", action: finishChapter) 33 | .tint(.red) 34 | } 35 | } 36 | .padding() 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Platforms/iPhone/iPhoneComicPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iPhoneComicPicker.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | struct iPhoneComicPicker: View { 12 | @Binding var selection: ComicType 13 | 14 | var body: some View { 15 | HStack(spacing: 10) { 16 | ForEach(ComicType.allCases, id: \.self) { type in 17 | Text(type.title) 18 | .withFont(textColor: selection == type ? Color.white : type.color) 19 | .padding(.vertical, 8) 20 | .padding(.horizontal, 16) 21 | .background(selection == type ? type.color : Color.clear) 22 | .cornerRadius(8) 23 | .onTapGesture { 24 | selection = type 25 | } 26 | } 27 | } 28 | .padding() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Platforms/iPhone/iPhoneMainTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iPhoneMainTabView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | struct iPhoneMainTabView: View { 12 | @Binding var path: NavigationPath 13 | @ViewBuilder var comicContent: () -> ComicTab 14 | @ViewBuilder var settingsTab: () -> SettingsTab 15 | 16 | var body: some View { 17 | TabView { 18 | ComicNavStack(path: $path, content: comicContent) 19 | .tabItem { 20 | Label("Comic", systemImage: "book") 21 | } 22 | 23 | settingsTab() 24 | .tabItem { 25 | Label("Settings", systemImage: "gearshape") 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Settings/Main/SettingsFeatureNavStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsFeatureNavStack.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 12/11/24. 6 | // 7 | 8 | import SwiftUI 9 | import NnSwiftUIKit 10 | import DBMultiverseComicKit 11 | 12 | struct SettingsFeatureNavStack: View { 13 | @Binding var language: ComicLanguage 14 | @StateObject var viewModel: SettingsViewModel 15 | 16 | let canDismiss: Bool 17 | 18 | var body: some View { 19 | NavStack(title: "Settings") { 20 | VStack { 21 | SettingsFormView(viewModel: viewModel, language: language) 22 | 23 | Text(NnAppVersionCache.getDeviceVersionDetails(mainBundle: .main)) 24 | .font(.caption) 25 | } 26 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) 27 | .onAppear { 28 | viewModel.loadCachedChapters() 29 | } 30 | .animation(.easeInOut, value: viewModel.cachedChapters) 31 | .withNavBarDismissButton(isActive: canDismiss, dismissType: .xmark) 32 | .showingAlert("Error", message: "Something went wrong when trying to clear the caches folder", isPresented: $viewModel.showingErrorAlert) 33 | .showingAlert("Cached Cleared!", message: "All images have been removed from the caches folder", isPresented: $viewModel.showingClearedCacheAlert) 34 | .navigationDestination(item: $viewModel.route) { route in 35 | switch route { 36 | case .cacheList: 37 | CacheChapterListView(chapters: viewModel.cachedChapters) 38 | .navigationTitle("Cached Chapters") 39 | case .disclaimer: 40 | DisclaimerView() 41 | case .languageSelection: 42 | LanguageSelectionView(selection: language) { updatedLanguage in 43 | if updatedLanguage != language { 44 | viewModel.clearCache() 45 | language = updatedLanguage 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | 55 | // MARK: - Preview 56 | #Preview { 57 | SettingsFeatureNavStack(language: .constant(.english), viewModel: .init(), canDismiss: true) 58 | } 59 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Settings/Main/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewModel.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 12/12/24. 6 | // 7 | 8 | import Foundation 9 | import DBMultiverseComicKit 10 | 11 | final class SettingsViewModel: ObservableObject { 12 | @Published var route: SettingsRoute? 13 | @Published var showingErrorAlert = false 14 | @Published var showingClearedCacheAlert = false 15 | @Published var cachedChapters: [CachedChapter] = [] 16 | 17 | private let fileManager: FileManager 18 | 19 | /// Initializes the ViewModel with an optional file manager. 20 | init(fileManager: FileManager = .default) { 21 | self.fileManager = fileManager 22 | } 23 | } 24 | 25 | // MARK: - Actions 26 | extension SettingsViewModel { 27 | func showView(_ route: SettingsRoute) { 28 | self.route = route 29 | } 30 | 31 | func makeURL(for link: SettingsLinkItem, language: ComicLanguage) -> URL? { 32 | return URLFactory.makeURL(language: language, pathComponent: link.pathComponent) 33 | } 34 | 35 | /// Clears all cached data from the app's cache directory. 36 | func clearCache() { 37 | let cacheDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! 38 | do { 39 | let contents = try fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) 40 | for file in contents { 41 | try fileManager.removeItem(at: file) 42 | } 43 | cachedChapters = [] 44 | showingClearedCacheAlert = true 45 | } catch { 46 | showingErrorAlert = true 47 | } 48 | } 49 | 50 | /// Loads cached chapter data from the cache directory. 51 | func loadCachedChapters() { 52 | let cacheDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! 53 | let chaptersDirectory = cacheDirectory.appendingPathComponent("Chapters") 54 | var chapters: [CachedChapter] = [] 55 | do { 56 | let chapterFolders = try fileManager.contentsOfDirectory(at: chaptersDirectory, includingPropertiesForKeys: nil) 57 | for folder in chapterFolders { 58 | let chapterNumber = folder.lastPathComponent.replacingOccurrences(of: "Chapter_", with: "") 59 | let images = try fileManager.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) 60 | let imageCount = images.filter { $0.pathExtension.lowercased() == "jpg" }.count 61 | chapters.append(.init(number: chapterNumber, imageCount: imageCount)) 62 | } 63 | } catch { 64 | print("Failed to load cached chapters: \(error.localizedDescription)") 65 | } 66 | cachedChapters = chapters 67 | } 68 | } 69 | 70 | 71 | // MARK: - Dependencies 72 | enum SettingsRoute { 73 | case cacheList, languageSelection, disclaimer 74 | } 75 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Settings/Model/CachedChapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CachedChapter.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/5/25. 6 | // 7 | 8 | /// Represents a cached chapter with its number and image count. 9 | struct CachedChapter: Hashable { 10 | let number: String 11 | let imageCount: Int 12 | } 13 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Settings/Model/SettingsLinkItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsLinkItem.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/4/25. 6 | // 7 | 8 | /// Represents the different link items available in the settings. 9 | enum SettingsLinkItem: CaseIterable { 10 | case authors 11 | case universeHelp 12 | case tournamentHelp 13 | } 14 | 15 | // MARK: - Helpers 16 | extension SettingsLinkItem { 17 | /// Returns the display name of the link item. 18 | var name: String { 19 | switch self { 20 | case .authors: 21 | return "Authors" 22 | case .universeHelp: 23 | return "Universe Help" 24 | case .tournamentHelp: 25 | return "Tournament Help" 26 | } 27 | } 28 | 29 | var pathComponent: URLWebsitePathComponent { 30 | switch self { 31 | case .authors: 32 | return .authors 33 | case .universeHelp: 34 | return .universeHelp 35 | case .tournamentHelp: 36 | return .tournamentHelp 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Settings/SubViews/CacheChapterListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CacheChapterListView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/9/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CacheChapterListView: View { 11 | let chapters: [CachedChapter] 12 | 13 | var body: some View { 14 | List(chapters, id: \.number) { chapter in 15 | HStack { 16 | Text("Chapter \(chapter.number)") 17 | Spacer() 18 | Text("\(chapter.imageCount) Images") 19 | .foregroundColor(.secondary) 20 | } 21 | } 22 | } 23 | } 24 | 25 | 26 | // MARK: - Preview 27 | #Preview { 28 | CacheChapterListView(chapters: []) 29 | } 30 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Settings/SubViews/LanguageSelectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LanguageSelectionView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/9/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | struct LanguageSelectionView: View { 12 | @State var selection: ComicLanguage 13 | @State private var didChangeLanguage = false 14 | @Environment(\.dismiss) private var dismiss 15 | 16 | let updateLanguage: (ComicLanguage) -> Void 17 | 18 | var body: some View { 19 | VStack { 20 | Text(selection.displayName) 21 | .withFont(.largeTitle, textColor: didChangeLanguage ? .red : .primary, autoSizeLineLimit: 1) 22 | 23 | LanguagePicker(selection: $selection) 24 | .padding(.vertical) 25 | 26 | VStack { 27 | Spacer() 28 | Text("If you change your language, all your cached image data will be reset.") 29 | .padding() 30 | .withFont() 31 | .multilineTextAlignment(.center) 32 | 33 | Spacer() 34 | Button("Update Language") { 35 | updateLanguage(selection) 36 | dismiss() 37 | } 38 | .tint(.red) 39 | .withFont() 40 | .buttonStyle(.borderedProminent) 41 | 42 | Spacer() 43 | } 44 | .onlyShow(when: didChangeLanguage) 45 | } 46 | .navigationBarBackButtonHidden(true) 47 | .animation(.easeInOut, value: didChangeLanguage) 48 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) 49 | .trackingItemChanges(item: selection, itemDidChange: $didChangeLanguage) 50 | .withDiscardChangesNavBarDismissButton( 51 | message: "You've selected a new language.", 52 | itemToModify: selection, 53 | dismissButtonInfo: .init(prompt: "Don't change language.") 54 | ) 55 | } 56 | } 57 | 58 | 59 | // MARK: - Preview 60 | #Preview { 61 | LanguageSelectionView(selection: .english, updateLanguage: { _ in }) 62 | } 63 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Settings/SubViews/SettingsDisclaimerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsDisclaimerView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/10/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | struct SettingsDisclaimerView: View { 12 | var body: some View { 13 | VStack { 14 | ComicNavBar() 15 | DisclaimerView() 16 | } 17 | } 18 | } 19 | 20 | // MARK: - Preview 21 | #Preview { 22 | SettingsDisclaimerView() 23 | } 24 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Settings/SubViews/SettingsFormView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsFormView.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/9/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | struct SettingsFormView: View { 12 | @ObservedObject var viewModel: SettingsViewModel 13 | 14 | let language: ComicLanguage 15 | 16 | var body: some View { 17 | Form { 18 | DynamicSection("Cached Data") { 19 | VStack { 20 | Text("View Cached Chapters") 21 | .foregroundColor(.blue) 22 | .withFont() 23 | .tappable(withChevron: true) { 24 | viewModel.showView(.cacheList) 25 | } 26 | 27 | Divider() 28 | 29 | HapticButton("Clear All Cached Data", action: viewModel.clearCache) 30 | .padding() 31 | .tint(.red) 32 | .buttonStyle(.bordered) 33 | .withFont(textColor: .red) 34 | .frame(maxWidth: .infinity) 35 | } 36 | .showingConditionalView(when: viewModel.cachedChapters.isEmpty) { 37 | Text("No cached data") 38 | } 39 | } 40 | 41 | DynamicSection("Language") { 42 | Text(language.displayName) 43 | .padding(5) 44 | .textLinearGradient(.redText) 45 | .tappable(withChevron: true) { 46 | viewModel.showView(.languageSelection) 47 | } 48 | .withFont() 49 | } 50 | 51 | DynamicSection("Web Comic Links") { 52 | ForEach(SettingsLinkItem.allCases, id: \.name) { link in 53 | if let url = viewModel.makeURL(for: link, language: language) { 54 | Link(link.name, destination: url) 55 | .padding(.vertical, 10) 56 | .textLinearGradient(.lightStarrySky) 57 | .asRowItem(withChevron: true) 58 | .withFont() 59 | } 60 | } 61 | } 62 | } 63 | .scrollContentBackground(.hidden) 64 | } 65 | } 66 | 67 | 68 | // MARK: - Preview 69 | #Preview { 70 | SettingsFormView(viewModel: .init(), language: .english) 71 | } 72 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Shared/Language/ComicLanguage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicLanguage.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/9/25. 6 | // 7 | 8 | enum ComicLanguage: String, CaseIterable { 9 | case english = "en" 10 | case french = "fr" 11 | case italian = "it" 12 | case spanish = "es" 13 | case brazilianPortuguese = "pt_BR" 14 | case polish = "pl" 15 | case latinSpanish = "es_CO" 16 | case german = "de" 17 | case catalan = "ct_CT" 18 | case portuguese = "pt" 19 | case japanese = "jp" 20 | case chinese = "cn" 21 | case hungarian = "hu_HU" 22 | case dutch = "nl" 23 | case korean = "kr_KR" 24 | case turkish = "tr_TR" 25 | case arabic = "ar_JO" 26 | case veneto = "xx_VE" 27 | case lombard = "xx_LMO" 28 | case greek = "gr_GR" 29 | case basque = "eu_EH" 30 | case swedish = "sv_SE" 31 | case hebrew = "he_HE" 32 | case galician = "ga_ES" 33 | case russian = "ru_RU" 34 | case corsican = "co_FR" 35 | case lithuanian = "lt_LT" 36 | case latin = "la_LA" 37 | case danish = "da_DK" 38 | case romanian = "ro_RO" 39 | case finnish = "fi_FI" 40 | case croatian = "hr_HR" 41 | case norwegian = "no_NO" 42 | case filipino = "tl_PI" 43 | case bulgarian = "bg_BG" 44 | case breton = "br_FR" 45 | case parodySalagir = "fr_PA" 46 | } 47 | 48 | 49 | // MARK: - DisplayName 50 | extension ComicLanguage { 51 | var displayName: String { 52 | switch self { 53 | case .english: 54 | return "English" 55 | case .french: 56 | return "Français" 57 | case .italian: 58 | return "Italiano" 59 | case .spanish: 60 | return "Español" 61 | case .brazilianPortuguese: 62 | return "Português Brasileiro" 63 | case .polish: 64 | return "Polski" 65 | case .latinSpanish: 66 | return "Español Latino" 67 | case .german: 68 | return "Deutsch" 69 | case .catalan: 70 | return "Català" 71 | case .portuguese: 72 | return "Português" 73 | case .japanese: 74 | return "日本語" 75 | case .chinese: 76 | return "中文" 77 | case .hungarian: 78 | return "Magyar" 79 | case .dutch: 80 | return "Nederlands" 81 | case .korean: 82 | return "Korean" 83 | case .turkish: 84 | return "Turc" 85 | case .arabic: 86 | return "اللغة العربية" 87 | case .veneto: 88 | return "Vèneto" 89 | case .lombard: 90 | return "Lombard" 91 | case .greek: 92 | return "Ελληνικά" 93 | case .basque: 94 | return "Euskera" 95 | case .swedish: 96 | return "Svenska" 97 | case .hebrew: 98 | return "עִבְרִית" 99 | case .galician: 100 | return "Galego" 101 | case .russian: 102 | return "Русский" 103 | case .corsican: 104 | return "Corsu" 105 | case .lithuanian: 106 | return "Lietuviškai" 107 | case .latin: 108 | return "Latine" 109 | case .danish: 110 | return "Dansk" 111 | case .romanian: 112 | return "România" 113 | case .finnish: 114 | return "Suomeksi" 115 | case .croatian: 116 | return "Croatian" 117 | case .norwegian: 118 | return "Norsk" 119 | case .filipino: 120 | return "Filipino" 121 | case .bulgarian: 122 | return "Български" 123 | case .breton: 124 | return "Brezhoneg" 125 | case .parodySalagir: 126 | return "Parodie Salagir" 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Shared/Language/LanguagePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LanguagePicker.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/9/25. 6 | // 7 | 8 | import SwiftUI 9 | import DBMultiverseComicKit 10 | 11 | struct LanguagePicker: View { 12 | @Binding var selection: ComicLanguage 13 | 14 | var body: some View { 15 | Picker("Language", selection: $selection) { 16 | ForEach(ComicLanguage.allCases, id: \.rawValue) { language in 17 | Text(language.displayName) 18 | .withFont() 19 | .tag(language) 20 | } 21 | } 22 | .pickerStyle(.wheel) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Shared/Utilities/CustomError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomError.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 11/30/24. 6 | // 7 | 8 | import Foundation 9 | import NnSwiftUIKit 10 | 11 | enum CustomError: Error, NnDisplayableError { 12 | case urlError 13 | case loadHTMLError 14 | case parseHTMLError 15 | 16 | var message: String { 17 | switch self { 18 | case .urlError: 19 | return "failed to fetch data from url" 20 | case .loadHTMLError: 21 | return "unable to load ChapterList HTML" 22 | case .parseHTMLError: 23 | return "unable to parse ChapterList HTML" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Shared/Utilities/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extensions.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 11/14/24. 6 | // 7 | 8 | extension String { 9 | static let lastReadSpecialPage = "lastReadSpecialPage" 10 | static let lastReadMainStoryPage = "lastReadMainStoryPage" 11 | static let completedChapterListKey = "completedChapterListKey" 12 | static let currentlyReadingChapterKey = "currentlyReadingChapterKey" 13 | static let baseWebsiteURLString = "https://www.dragonball-multiverse.com" 14 | 15 | static func makeFullURLString(suffix: String) -> String { 16 | return "\(baseWebsiteURLString)\(suffix)" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Shared/Utilities/URLFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLFactory.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/9/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A factory for generating URLs used in the DBMultiverse application. 11 | enum URLFactory { 12 | /// The base URL string for the Dragonball Multiverse website. 13 | private static let baseWebsiteURLString = "https://www.dragonball-multiverse.com" 14 | } 15 | 16 | // MARK: - Factory 17 | extension URLFactory { 18 | /// Creates a URL for a specific language and path component. 19 | /// - Parameters: 20 | /// - language: The language for the comic (e.g., English, French). 21 | /// - pathComponent: The specific path component for the URL. 22 | /// - Returns: A URL object if the input parameters are valid; otherwise, `nil`. 23 | static func makeURL(language: ComicLanguage, pathComponent: URLWebsitePathComponent) -> URL? { 24 | return .init(string: "\(baseWebsiteURLString)/\(language.rawValue)/\(pathComponent.path)") 25 | } 26 | } 27 | 28 | // MARK: - Dependencies 29 | /// Represents the different path components that can be appended to the base website URL. 30 | enum URLWebsitePathComponent { 31 | case chapterList 32 | case comicPage(Int) 33 | case authors 34 | case universeHelp 35 | case tournamentHelp 36 | } 37 | 38 | extension URLWebsitePathComponent { 39 | /// Converts the `URLWebsitePathComponent` case into a path string to be appended to the base URL. 40 | var path: String { 41 | switch self { 42 | case .chapterList: 43 | return "chapters.html?comic=page&chaptersmode=1" 44 | case .comicPage(let page): 45 | return "page-\(page).html" 46 | case .authors: 47 | return "the-authors.html" 48 | case .universeHelp: 49 | return "listing.html" 50 | case .tournamentHelp: 51 | return "tournament.html" 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/MainFeatures/Shared/Views/HapticButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HapticButton.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 12/12/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HapticButton: View { 11 | let title: String 12 | let action: () -> Void 13 | 14 | init(_ title: String, action: @escaping () -> Void) { 15 | self.title = title 16 | self.action = action 17 | } 18 | 19 | var body: some View { 20 | Button(title) { 21 | let generator = UIImpactFeedbackGenerator(style: .medium) 22 | generator.impactOccurred() 23 | action() 24 | } 25 | .buttonStyle(.bordered) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/Networking/SharedComicNetworkingManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SharedComicNetworkingManager.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/9/25. 6 | // 7 | 8 | import Foundation 9 | 10 | enum SharedComicNetworkingManager { 11 | static func fetchData(from url: URL?) async throws -> Data { 12 | guard let url else { 13 | throw CustomError.urlError 14 | } 15 | 16 | return try await URLSession.shared.data(from: url).0 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/SwiftData/EventHandler/SwiftDataChapterListEventHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftDataChapterListEventHandler.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import Foundation 9 | import DBMultiverseComicKit 10 | 11 | struct SwiftDataChapterListEventHandler { 12 | let lastReadSpecialPage: Int 13 | let lastReadMainStoryPage: Int 14 | let chapterList: SwiftDataChapterList 15 | let onStartNextChapter: (Chapter) -> Void 16 | } 17 | 18 | 19 | // MARK: - ChapterListEventHandler 20 | extension SwiftDataChapterListEventHandler: ChapterListEventHandler { 21 | func makeImageURL(for chapter: Chapter) -> URL? { 22 | return .init(string: .makeFullURLString(suffix: chapter.coverImageURL)) 23 | } 24 | 25 | func startNextChapter(currentChapter: Chapter) { 26 | if let nextChapter = getNextChapter(currentChapter: currentChapter) { 27 | onStartNextChapter(nextChapter) 28 | } 29 | } 30 | 31 | func toggleReadStatus(for chapter: DBMultiverseComicKit.Chapter) { 32 | if chapter.didFinishReading { 33 | chapterList.unread(chapter) 34 | } else { 35 | chapterList.markChapterAsRead(chapter) 36 | } 37 | } 38 | 39 | func makeSections(type: ComicType) -> [ChapterSection] { 40 | var sections = [ChapterSection]() 41 | let currentChapter = getCurrentChapter(type: type) 42 | 43 | if let currentChapter { 44 | sections.append(.init(type: .currentChapter, chapters: [currentChapter])) 45 | } 46 | 47 | switch type { 48 | case .story: 49 | sections.append(.init(type: .chapterList(title: "Main Story Chapters"), chapters: mainStoryChapters.filter({ $0 != currentChapter }))) 50 | case .specials: 51 | Dictionary(grouping: univereSpecialChapters.filter({ $0 != currentChapter }), by: { $0.universe! }) 52 | .sorted(by: { $0.key < $1.key }) 53 | .map { ChapterSection(type: .chapterList(title: "Universe \($0.key)"), chapters: $0.value) } 54 | .forEach { sections.append($0) } 55 | } 56 | 57 | return sections 58 | } 59 | } 60 | 61 | 62 | // MARK: - Private Helpers 63 | private extension SwiftDataChapterListEventHandler { 64 | var chapters: [Chapter] { 65 | return chapterList.chapters 66 | } 67 | 68 | var mainStoryChapters: [Chapter] { 69 | return chapters.filter({ $0.universe == nil }) 70 | } 71 | 72 | var univereSpecialChapters: [Chapter] { 73 | return chapters.filter({ $0.universe != nil }) 74 | } 75 | 76 | func getNextChapter(currentChapter: Chapter) -> Chapter? { 77 | guard let index = chapters.firstIndex(where: { $0.number == currentChapter.number }) else { 78 | return nil 79 | } 80 | 81 | let nextIndex = index + 1 82 | 83 | guard chapters.indices.contains(nextIndex) else { 84 | return nil 85 | } 86 | 87 | return chapters[nextIndex] 88 | } 89 | 90 | func getCurrentChapter(type: ComicType) -> Chapter? { 91 | switch type { 92 | case .story: 93 | return mainStoryChapters.first(where: { $0.containsPage(lastReadMainStoryPage) }) 94 | case .specials: 95 | return univereSpecialChapters.first(where: { $0.containsPage(lastReadSpecialPage) }) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/SwiftData/Model/SwiftDataChapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftDataChapter.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 12/10/24. 6 | // 7 | 8 | import SwiftData 9 | 10 | /// Represents a comic chapter stored in the database. 11 | @Model 12 | final class SwiftDataChapter { 13 | /// The unique name of the chapter. 14 | @Attribute(.unique) var name: String 15 | 16 | /// The unique number of the chapter. 17 | @Attribute(.unique) var number: Int 18 | 19 | /// The starting page of the chapter. 20 | var startPage: Int 21 | 22 | /// The ending page of the chapter. 23 | var endPage: Int 24 | 25 | /// The universe number associated with the chapter, if applicable. 26 | var universe: Int? 27 | 28 | /// The last page the user read in this chapter, if applicable. 29 | var lastReadPage: Int? 30 | 31 | /// The URL for the chapter's cover image. 32 | var coverImageURL: String 33 | 34 | /// Indicates whether the chapter has been completely read by the user. 35 | var didFinishReading: Bool = false 36 | 37 | /// Initializes a new `SwiftDataChapter` instance. 38 | /// - Parameters: 39 | /// - name: The name of the chapter. 40 | /// - number: The unique number of the chapter. 41 | /// - startPage: The starting page of the chapter. 42 | /// - endPage: The ending page of the chapter. 43 | /// - universe: The universe number associated with the chapter, if any. 44 | /// - lastReadPage: The last page read in this chapter, if any. 45 | /// - coverImageURL: The URL for the chapter's cover image. 46 | init(name: String, number: Int, startPage: Int, endPage: Int, universe: Int?, lastReadPage: Int?, coverImageURL: String) { 47 | self.name = name 48 | self.number = number 49 | self.startPage = startPage 50 | self.endPage = endPage 51 | self.universe = universe 52 | self.lastReadPage = lastReadPage 53 | self.coverImageURL = coverImageURL 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/SwiftData/Model/SwiftDataChapterList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftDataChapterList.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import DBMultiverseComicKit 9 | 10 | typealias SwiftDataChapterList = [SwiftDataChapter] 11 | 12 | 13 | // MARK: - Helpers 14 | extension SwiftDataChapterList { 15 | var chapters: [Chapter] { 16 | return map { 17 | .init( 18 | name: $0.name, 19 | number: $0.number, 20 | startPage: $0.startPage, 21 | endPage: $0.endPage, 22 | universe: $0.universe, 23 | lastReadPage: $0.lastReadPage, 24 | coverImageURL: $0.coverImageURL, 25 | didFinishReading: $0.didFinishReading 26 | ) 27 | } 28 | } 29 | 30 | func unread(_ chapter: Chapter) { 31 | getChapter(chapter)?.didFinishReading = false 32 | } 33 | } 34 | 35 | 36 | // MARK: - ProgressHandler 37 | extension SwiftDataChapterList: ChapterProgressHandler { 38 | func updateLastReadPage(page: Int, chapter: Chapter) { 39 | getChapter(chapter)?.lastReadPage = page 40 | } 41 | 42 | func markChapterAsRead(_ chapter: Chapter) { 43 | getChapter(chapter)?.didFinishReading = true 44 | } 45 | } 46 | 47 | 48 | // MARK: - Private Helpers 49 | private extension SwiftDataChapterList { 50 | func getChapter(_ chapter: Chapter) -> SwiftDataChapter? { 51 | return first(where: { $0.name == chapter.name }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/SwiftData/ViewModifiers/PreviewModifiersViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewModifiersViewModifier.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 12/11/24. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftData 10 | import NnSwiftUIKit 11 | 12 | struct PreviewModifiersViewModifier: ViewModifier { 13 | func body(content: Content) -> some View { 14 | MainActor.assumeIsolated { 15 | content 16 | .withNnLoadingView() 17 | .withNnErrorHandling() 18 | .environment(\.isPreview, true) 19 | .modelContainer(PreviewSampleData.container) 20 | } 21 | } 22 | } 23 | 24 | extension View { 25 | /// Applies a set of modifiers to configure a view for use in previews. 26 | /// 27 | /// This method uses `PreviewModifiersViewModifier` to add common modifiers for previewing 28 | /// SwiftUI views, such as a loading view, error handling, and preview-specific environment settings. 29 | /// It also injects a model container with in-memory storage for preview data. 30 | /// 31 | /// - Returns: A modified view configured with preview-specific settings. 32 | func withPreviewModifiers() -> some View { 33 | modifier(PreviewModifiersViewModifier()) 34 | } 35 | } 36 | 37 | /// Provides sample data and a model container for use in SwiftUI previews. 38 | actor PreviewSampleData { 39 | /// A `ModelContainer` configured with in-memory storage and populated with sample data. 40 | /// This container is used to supply data for previews without affecting persistent storage. 41 | @MainActor 42 | static var container: ModelContainer = { 43 | // Create an in-memory model container for the `SwiftDataChapter` type. 44 | let container = try! ModelContainer( 45 | for: SwiftDataChapter.self, 46 | configurations: .init(isStoredInMemoryOnly: true) 47 | ) 48 | 49 | // Populate the container's main context with sample data. 50 | SwiftDataChapter.sampleList.forEach { container.mainContext.insert($0) } 51 | 52 | return container 53 | }() 54 | 55 | /// A sample chapter from the preloaded `sampleList` for use in previews. 56 | @MainActor 57 | static var sampleChapter: SwiftDataChapter { 58 | // Ensure the container is initialized. 59 | let _ = PreviewSampleData.container 60 | 61 | // Return the first sample chapter. 62 | return SwiftDataChapter.sampleList[0] 63 | } 64 | } 65 | 66 | extension SwiftDataChapter { 67 | static var sampleList: [SwiftDataChapter] { 68 | return [ 69 | .init(name: "A really strange tournament!", number: 1, startPage: 0, endPage: 23, universe: nil, lastReadPage: 17, coverImageURL: ""), 70 | .init(name: "Lots of old foes here!", number: 2, startPage: 24, endPage: 47, universe: nil, lastReadPage: nil, coverImageURL: ""), 71 | .init(name: "Universe 3: visions of the future", number: 20, startPage: 425, endPage: 449, universe: 3, lastReadPage: nil, coverImageURL: ""), 72 | .init(name: "Deus Ex Machina", number: 25, startPage: 547, endPage: 555, universe: 1, lastReadPage: 549, coverImageURL: "") 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/SwiftData/ViewModifiers/SwiftDataChapterStorageViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftDataChapterStorageViewModifier.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftData 10 | import DBMultiverseComicKit 11 | 12 | struct SwiftDataChapterStorageViewModifier: ViewModifier { 13 | @Environment(\.modelContext) private var modelContext 14 | @Query private var existingChapters: [SwiftDataChapter] 15 | 16 | let chapters: [Chapter] 17 | 18 | private func shouldUpdateChapter(existing: SwiftDataChapter, chapter: Chapter) -> Bool { 19 | if existing.universe != chapter.universe { 20 | return true 21 | } else if existing.name != chapter.name { 22 | return true 23 | } else if existing.endPage != chapter.endPage { 24 | return true 25 | } 26 | 27 | return false 28 | } 29 | 30 | func body(content: Content) -> some View { 31 | content 32 | .onChange(of: chapters) { _, newList in 33 | for chapter in newList { 34 | if let index = existingChapters.firstIndex(where: { $0.number == chapter.number }) { 35 | if shouldUpdateChapter(existing: existingChapters[index], chapter: chapter) { 36 | modelContext.delete(existingChapters[index]) 37 | modelContext.insert(SwiftDataChapter(chapter: chapter)) 38 | } 39 | } else { 40 | modelContext.insert(SwiftDataChapter(chapter: chapter)) 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | extension View { 48 | /// Synchronizes a list of `Chapter` objects with SwiftData storage by applying the `SwiftDataChapterStorageViewModifier`. 49 | /// - Parameter chapters: The list of `Chapter` objects to sync. 50 | func syncChaptersWithSwiftData(chapters: [Chapter]) -> some View { 51 | modifier(SwiftDataChapterStorageViewModifier(chapters: chapters)) 52 | } 53 | } 54 | 55 | 56 | // MARK: - Extension Dependencies 57 | fileprivate extension SwiftDataChapter { 58 | convenience init(chapter: Chapter) { 59 | self.init(name: chapter.name, number: chapter.number, startPage: chapter.startPage, endPage: chapter.endPage, universe: chapter.universe, lastReadPage: chapter.lastReadPage, coverImageURL: chapter.coverImageURL) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /DBMultiverse/Sources/Widgets/WidgetSyncViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WidgetSyncViewModifier.swift 3 | // DBMultiverse 4 | // 5 | // Created by Nikolai Nobadi on 1/10/25. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | import DBMultiverseComicKit 11 | 12 | struct WidgetSyncViewModifier: ViewModifier { 13 | @Environment(\.scenePhase) private var scenePhase 14 | @State private var lastSavedChapterData: CurrentChapterData? 15 | 16 | func body(content: Content) -> some View { 17 | content 18 | .onChange(of: scenePhase) { 19 | // to prevent widget from being reloaded too often 20 | if let currentChapterData = CoverImageCache.shared.loadCurrentChapterData(), lastSavedChapterData != currentChapterData { 21 | WidgetCenter.shared.reloadAllTimelines() 22 | lastSavedChapterData = currentChapterData 23 | } 24 | } 25 | } 26 | } 27 | 28 | extension View { 29 | func syncWidgetData() -> some View { 30 | modifier(WidgetSyncViewModifier()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DBMultiverseComicKit", 8 | platforms: [ 9 | .iOS(.v17) 10 | ], 11 | products: [ 12 | .library( 13 | name: "DBMultiverseComicKit", 14 | targets: ["DBMultiverseComicKit"]), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/nikolainobadi/NnSwiftUIKit.git", branch: "remove-nn-prefixes") 18 | ], 19 | targets: [ 20 | .target( 21 | name: "DBMultiverseComicKit", 22 | dependencies: [ 23 | "NnSwiftUIKit" 24 | ] 25 | ), 26 | .testTarget( 27 | name: "DBMultiverseComicKitTests", 28 | dependencies: ["DBMultiverseComicKit"] 29 | ), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/CoverImageCache/CoverImageCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoverImageCache.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import UIKit 9 | import Foundation 10 | 11 | public class CoverImageCache { 12 | private let sharedContainerDirectory: URL 13 | private let fileManager = FileManager.default 14 | private let imageFileName = "chapterCoverImage.jpg" 15 | private let jsonFileName = "currentChapterData.json" 16 | 17 | public static let shared = CoverImageCache() 18 | 19 | private init() { 20 | guard let appGroupDirectory = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.com.nobadi.dbm") else { 21 | fatalError("Failed to get App Group directory") 22 | } 23 | 24 | sharedContainerDirectory = appGroupDirectory 25 | } 26 | } 27 | 28 | 29 | // MARK: - Load 30 | public extension CoverImageCache { 31 | func loadCurrentChapterData() -> CurrentChapterData? { 32 | let jsonFileURL = sharedContainerDirectory.appendingPathComponent(jsonFileName) 33 | 34 | do { 35 | let jsonData = try Data(contentsOf: jsonFileURL) 36 | let chapterData = try JSONDecoder().decode(CurrentChapterData.self, from: jsonData) 37 | return chapterData 38 | } catch { 39 | print("Failed to load current chapter data JSON: \(error)") 40 | return nil 41 | } 42 | } 43 | } 44 | 45 | 46 | // MARK: - Save 47 | public extension CoverImageCache { 48 | func saveCurrentChapterData(imageData: Data, metadata: CoverImageMetaData) { 49 | let imageFileURL = sharedContainerDirectory.appendingPathComponent(imageFileName) 50 | 51 | guard let compressedImageData = compressImageData(imageData) else { 52 | print("Failed to compress image data for chapter \(metadata.chapterNumber)") 53 | return 54 | } 55 | 56 | do { 57 | try compressedImageData.write(to: imageFileURL) 58 | } catch { 59 | print("Unable to save compressed cover image for chapter \(metadata.chapterNumber): \(error)") 60 | return 61 | } 62 | 63 | let chapterData = CurrentChapterData(number: metadata.chapterNumber, name: metadata.chapterName, progress: metadata.readProgress, coverImagePath: imageFileURL.path) 64 | let jsonFileURL = sharedContainerDirectory.appendingPathComponent(jsonFileName) 65 | 66 | do { 67 | let jsonData = try JSONEncoder().encode(chapterData) 68 | try jsonData.write(to: jsonFileURL) 69 | print("Current chapter data saved successfully to \(jsonFileURL.path)") 70 | } catch { 71 | print("Failed to save current chapter data JSON: \(error)") 72 | } 73 | } 74 | 75 | func saveCurrentChapterData(chapter: Int, name: String, progress: Int, imageData: Data) { 76 | let imageFileURL = sharedContainerDirectory.appendingPathComponent(imageFileName) 77 | 78 | guard let compressedImageData = compressImageData(imageData) else { 79 | print("Failed to compress image data for chapter \(chapter)") 80 | return 81 | } 82 | 83 | do { 84 | try compressedImageData.write(to: imageFileURL) 85 | } catch { 86 | print("Unable to save compressed cover image for chapter \(chapter): \(error)") 87 | return 88 | } 89 | 90 | let chapterData = CurrentChapterData(number: chapter, name: name, progress: progress, coverImagePath: imageFileURL.path) 91 | let jsonFileURL = sharedContainerDirectory.appendingPathComponent(jsonFileName) 92 | 93 | do { 94 | let jsonData = try JSONEncoder().encode(chapterData) 95 | try jsonData.write(to: jsonFileURL) 96 | print("Current chapter data saved successfully to \(jsonFileURL.path)") 97 | } catch { 98 | print("Failed to save current chapter data JSON: \(error)") 99 | } 100 | } 101 | 102 | func updateProgress(to newProgress: Int) { 103 | let jsonFileURL = sharedContainerDirectory.appendingPathComponent(jsonFileName) 104 | 105 | do { 106 | let jsonData = try Data(contentsOf: jsonFileURL) 107 | var chapterData = try JSONDecoder().decode(CurrentChapterData.self, from: jsonData) 108 | 109 | chapterData = CurrentChapterData( 110 | number: chapterData.number, 111 | name: chapterData.name, 112 | progress: newProgress, 113 | coverImagePath: chapterData.coverImagePath 114 | ) 115 | 116 | saveChapterDataToFile(chapterData) 117 | print("Progress updated to \(newProgress)") 118 | } catch { 119 | print("Failed to update progress: \(error)") 120 | } 121 | } 122 | } 123 | 124 | 125 | // MARK: - Private Methods 126 | private extension CoverImageCache { 127 | func compressImageData(_ data: Data) -> Data? { 128 | guard let image = UIImage(data: data) else { return nil } 129 | 130 | return image.jpegData(compressionQuality: 0.7) 131 | } 132 | 133 | func saveChapterDataToFile(_ chapterData: CurrentChapterData) { 134 | let jsonFileURL = sharedContainerDirectory.appendingPathComponent(jsonFileName) 135 | 136 | do { 137 | let jsonData = try JSONEncoder().encode(chapterData) 138 | try jsonData.write(to: jsonFileURL) 139 | print("Chapter data saved successfully to \(jsonFileURL.path)") 140 | } catch { 141 | print("Failed to save chapter data JSON: \(error)") 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/CoverImageCache/CoverImageMetaData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoverImageMetaData.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/10/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct CoverImageMetaData { 11 | public let chapterName: String 12 | public let chapterNumber: Int 13 | public let readProgress: Int 14 | 15 | public init(chapterName: String, chapterNumber: Int, readProgress: Int) { 16 | self.chapterName = chapterName 17 | self.chapterNumber = chapterNumber 18 | self.readProgress = readProgress 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/CoverImageCache/CurrentChapterData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrentChapterData.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | public struct CurrentChapterData: Codable, Equatable { 9 | public let number: Int 10 | public let name: String 11 | public let progress: Int 12 | public let coverImagePath: String 13 | 14 | public init(number: Int, name: String, progress: Int, coverImagePath: String) { 15 | self.number = number 16 | self.name = name 17 | self.progress = progress 18 | self.coverImagePath = coverImagePath 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/List/ChapterListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterListView.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import SwiftUI 9 | import NnSwiftUIKit 10 | 11 | public struct ChapterListView: View { 12 | @State private var selection: ComicType = .story 13 | 14 | let imageSize: CGSize 15 | let eventHandler: ChapterListEventHandler 16 | let comicPicker: (Binding) -> ComicPicker 17 | 18 | public init(imageSize: CGSize, eventHandler: ChapterListEventHandler, @ViewBuilder comicPicker: @escaping (Binding) -> ComicPicker) { 19 | self.imageSize = imageSize 20 | self.eventHandler = eventHandler 21 | self.comicPicker = comicPicker 22 | } 23 | 24 | public var body: some View { 25 | VStack { 26 | comicPicker($selection) 27 | 28 | List(eventHandler.makeSections(type: selection), id: \.title) { section in 29 | DynamicSection(section.title, gradient: section.gradient) { 30 | ForEach(section.chapters, id: \.name) { chapter in 31 | ChapterRow(chapter, url: eventHandler.makeImageURL(for: chapter), imageSize: imageSize) 32 | .asNavLink(ChapterRoute(chapter: chapter, comicType: selection)) 33 | .withToggleReadSwipeAction(isRead: chapter.didFinishReading) { 34 | eventHandler.toggleReadStatus(for: chapter) 35 | } 36 | } 37 | 38 | if let chapter = section.chapters.first { 39 | Button { 40 | eventHandler.startNextChapter(currentChapter: chapter) 41 | } label: { 42 | Text("Start Reading Next Chapter") 43 | .withFont() 44 | .underline() 45 | .frame(maxWidth: .infinity) 46 | .textLinearGradient(.yellowText) 47 | } 48 | .onlyShow(when: section.canShowNextChapterButton && selection == .story) 49 | } 50 | } 51 | } 52 | .listStyle(.plain) 53 | } 54 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) 55 | } 56 | } 57 | 58 | 59 | // MARK: - Row 60 | fileprivate struct ChapterRow: View { 61 | let url: URL? 62 | let chapter: Chapter 63 | let imageSize: CGSize? 64 | 65 | init(_ chapter: Chapter, url: URL?, imageSize: CGSize?) { 66 | self.url = url 67 | self.chapter = chapter 68 | self.imageSize = imageSize 69 | } 70 | 71 | var body: some View { 72 | HStack { 73 | CustomAsyncImage(url: url, size: imageSize) 74 | 75 | VStack(alignment: .leading, spacing: 0) { 76 | Text(chapter.rowTitle) 77 | .withFont(.headline, autoSizeLineLimit: 1) 78 | 79 | Text(chapter.pageRangeText) 80 | .withFont(textColor: .secondary) 81 | 82 | if let lastReadPage = chapter.lastReadPage { 83 | Text("Last read page: \(lastReadPage)") 84 | .withFont(.caption2, textColor: .secondary) 85 | } 86 | } 87 | } 88 | .frame(maxWidth: .infinity, alignment: .leading) 89 | .overlay(alignment: .bottomTrailing) { 90 | if chapter.didFinishReading { 91 | Text("Finished") 92 | .padding() 93 | .withFont(.caption) 94 | .textLinearGradient(.redText) 95 | } 96 | } 97 | } 98 | } 99 | 100 | 101 | // MARK: - Dependencies 102 | public protocol ChapterListEventHandler { 103 | func toggleReadStatus(for chapter: Chapter) 104 | func startNextChapter(currentChapter: Chapter) 105 | func makeImageURL(for chapter: Chapter) -> URL? 106 | func makeSections(type: ComicType) -> [ChapterSection] 107 | } 108 | 109 | 110 | // MARK: - Extension Dependencies 111 | extension View { 112 | func withToggleReadSwipeAction(isRead: Bool, action: @escaping () -> Void) -> some View { 113 | withSwipeAction(info: .init(prompt: isRead ? "Unread" : "Complete"), systemImage: isRead ? "eraser.fill" : "book", tint: isRead ? .gray : .blue, edge: .leading, action: action) 114 | } 115 | } 116 | 117 | extension Chapter { 118 | var rowTitle: String { 119 | return "\(number) - \(name)" 120 | } 121 | 122 | var pageRangeText: String { 123 | return "Pages: \(startPage) - \(endPage)" 124 | } 125 | } 126 | 127 | extension ChapterSection { 128 | var canShowNextChapterButton: Bool { 129 | guard type == .currentChapter, let chapter = chapters.first else { 130 | return false 131 | } 132 | 133 | return chapter.didFinishReading 134 | } 135 | 136 | var gradient: LinearGradient? { 137 | switch type { 138 | case .currentChapter: 139 | return .lightStarrySky 140 | default: 141 | return nil 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/List/ChapterRoute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterRoute.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/9/25. 6 | // 7 | 8 | public struct ChapterRoute: Hashable { 9 | public let chapter: Chapter 10 | public let comicType: ComicType 11 | 12 | public init(chapter: Chapter, comicType: ComicType) { 13 | self.chapter = chapter 14 | self.comicType = comicType 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/List/ChapterSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterSection.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ChapterSection { 11 | public let type: ChapterSectionType 12 | public let chapters: [Chapter] 13 | 14 | public init(type: ChapterSectionType, chapters: [Chapter]) { 15 | self.type = type 16 | self.chapters = chapters 17 | } 18 | } 19 | 20 | 21 | // MARK: - Display Helpers 22 | extension ChapterSection { 23 | var isCurrentChapterSection: Bool { 24 | return type == .currentChapter 25 | } 26 | 27 | var title: String { 28 | switch type { 29 | case .currentChapter: 30 | return "Current Chapter" 31 | case .chapterList(let title): 32 | return title 33 | } 34 | } 35 | } 36 | 37 | 38 | // MARK: - Dependencies 39 | public enum ChapterSectionType: Equatable { 40 | case currentChapter 41 | case chapterList(title: String) 42 | } 43 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/List/CustomAsyncImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomAsyncImage.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomAsyncImage: View { 11 | let url: URL? 12 | let width: CGFloat 13 | let height: CGFloat 14 | 15 | init(url: URL?, size: CGSize?) { 16 | self.url = url 17 | self.width = size?.width ?? 50 18 | self.height = size?.height ?? 70 19 | } 20 | 21 | var body: some View { 22 | AsyncImage(url: url) { phase in 23 | switch phase { 24 | case .empty: 25 | Rectangle() 26 | .fill(Color.gray.opacity(0.3)) 27 | .frame(width: width, height: height) 28 | .cornerRadius(8) 29 | case .success(let image): 30 | image 31 | .resizable() 32 | .scaledToFit() 33 | .frame(width: width, height: height) 34 | .cornerRadius(8) 35 | .clipped() 36 | case .failure: 37 | Rectangle() 38 | .fill(Color.red.opacity(0.3)) 39 | .frame(width: width, height: height) 40 | .cornerRadius(8) 41 | @unknown default: 42 | EmptyView() 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/Model/Chapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Chapter.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Chapter: Hashable { 11 | public let name: String 12 | public let number: Int 13 | public let startPage: Int 14 | public let endPage: Int 15 | public let universe: Int? 16 | public let lastReadPage: Int? 17 | public let coverImageURL: String 18 | public let didFinishReading: Bool 19 | 20 | public init(name: String, number: Int, startPage: Int, endPage: Int, universe: Int?, lastReadPage: Int?, coverImageURL: String, didFinishReading: Bool) { 21 | self.name = name 22 | self.number = number 23 | self.startPage = startPage 24 | self.endPage = endPage 25 | self.universe = universe 26 | self.lastReadPage = lastReadPage 27 | self.coverImageURL = coverImageURL 28 | self.didFinishReading = didFinishReading 29 | } 30 | } 31 | 32 | 33 | // MARK: - Helpers 34 | public extension Chapter { 35 | func containsPage(_ page: Int) -> Bool { 36 | return page >= startPage && page <= endPage 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/Model/ComicType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicType.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Represents the type of comic, such as main story or specials. 11 | public enum ComicType: Int, Identifiable, CaseIterable { 12 | case story 13 | case specials 14 | 15 | public var id: Int { 16 | return rawValue 17 | } 18 | } 19 | 20 | 21 | // MARK: - Display Data 22 | public extension ComicType { 23 | /// The display title for the comic type. 24 | var title: String { 25 | switch self { 26 | case .story: 27 | return "Story" 28 | case .specials: 29 | return "Specials" 30 | } 31 | } 32 | 33 | /// The icon name for the comic type, typically used in UI components. 34 | var icon: String { 35 | switch self { 36 | case .story: 37 | return "book" 38 | case .specials: 39 | return "star" 40 | } 41 | } 42 | 43 | /// The navigation title for the comic type. 44 | var navTitle: String { 45 | switch self { 46 | case .story: 47 | return "Main Story" 48 | case .specials: 49 | return "Universe Specials" 50 | } 51 | } 52 | 53 | // /// The color associated with the comic type. 54 | var color: Color { 55 | switch self { 56 | case .story: 57 | return .blue 58 | case .specials: 59 | return .red 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/Model/PageInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PageInfo.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct PageInfo { 11 | public let chapter: Int 12 | public let pageNumber: Int 13 | public let secondPageNumber: Int? 14 | public let imageData: Data 15 | 16 | public init(chapter: Int, pageNumber: Int, secondPageNumber: Int?, imageData: Data) { 17 | self.chapter = chapter 18 | self.pageNumber = pageNumber 19 | self.secondPageNumber = secondPageNumber 20 | self.imageData = imageData 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/Page/ComicPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicPage.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ComicPage: Equatable { 11 | public let number: Int 12 | public let chapterName: String 13 | public let pagePosition: PagePosition 14 | public let imageData: Data 15 | 16 | public init(number: Int, chapterName: String, pagePosition: PagePosition, imageData: Data) { 17 | self.number = number 18 | self.chapterName = chapterName 19 | self.pagePosition = pagePosition 20 | self.imageData = imageData 21 | } 22 | } 23 | 24 | // MARK: - Display Helpers 25 | public extension ComicPage { 26 | var isFirstPage: Bool { 27 | return pagePosition.page == 0 28 | } 29 | 30 | var isLastPage: Bool { 31 | return pagePosition.page == pagePosition.endPage 32 | } 33 | 34 | var pagePositionText: String { 35 | guard let secondPage = pagePosition.secondPage else { 36 | return "\(pagePosition.page)/\(pagePosition.endPage)" 37 | } 38 | 39 | return "\(pagePosition.page)-\(secondPage)/\(pagePosition.endPage)" 40 | } 41 | } 42 | 43 | 44 | // MARK: - Dependencies 45 | public struct PagePosition: Equatable { 46 | public let page: Int 47 | public let secondPage: Int? 48 | public let endPage: Int 49 | 50 | public init(page: Int, secondPage: Int?, endPage: Int) { 51 | self.page = page 52 | self.secondPage = secondPage 53 | self.endPage = endPage 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/Page/ComicPageImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicPageImageView.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct ComicPageImageView: View { 11 | let page: ComicPage 12 | 13 | public init(page: ComicPage) { 14 | self.page = page 15 | } 16 | 17 | public var body: some View { 18 | VStack { 19 | Text(page.chapterName) 20 | Text(page.pagePositionText) 21 | .foregroundStyle(.secondary) 22 | 23 | Spacer() 24 | 25 | if let image = UIImage(data: page.imageData) { 26 | ZoomableImageView(image: image) 27 | .padding() 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/Page/ComicPageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicPageViewModel.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | 11 | public final class ComicPageViewModel: ObservableObject { 12 | @Published var pages: [PageInfo] 13 | @Published var currentPageNumber: Int 14 | @Published var didFetchInitialPages = false 15 | 16 | private let chapter: Chapter 17 | private let delegate: ComicPageDelegate 18 | 19 | private var cancellables = Set() 20 | 21 | public init(chapter: Chapter, currentPageNumber: Int, delegate: ComicPageDelegate, pages: [PageInfo] = []) { 22 | self.pages = pages 23 | self.delegate = delegate 24 | self.chapter = chapter 25 | self.currentPageNumber = chapter.getCurrentPage(currentPage: currentPageNumber) 26 | 27 | self.startObservers() 28 | } 29 | } 30 | 31 | 32 | // MARK: - Display Data 33 | public extension ComicPageViewModel { 34 | var currentPagePosition: PagePosition { 35 | return .init(page: currentPageNumber, secondPage: currentPageInfo?.secondPageNumber, endPage: chapter.endPage) 36 | } 37 | 38 | var currentPageInfo: PageInfo? { 39 | return pages.first(where: { $0.pageNumber == currentPageNumber }) 40 | } 41 | 42 | var currentPage: ComicPage? { 43 | guard let currentPageInfo else { 44 | return nil 45 | } 46 | 47 | return .init( 48 | number: currentPageInfo.pageNumber, 49 | chapterName: chapter.name, 50 | pagePosition: currentPagePosition, 51 | imageData: currentPageInfo.imageData 52 | ) 53 | } 54 | } 55 | 56 | 57 | // MARK: - Actions 58 | public extension ComicPageViewModel { 59 | func loadData() async throws { 60 | if !didFetchInitialPages { 61 | let initialPages = Array(currentPageNumber...(min(currentPageNumber + 4, chapter.endPage))) 62 | let fetchedPages = try await delegate.loadPages(initialPages) 63 | 64 | await setPages(fetchedPages) 65 | } 66 | } 67 | 68 | func loadRemainingPages() { 69 | Task { 70 | let allPages = Array(chapter.startPage...chapter.endPage) 71 | let fetchedPages = pages.map({ $0.pageNumber }) 72 | let remainingPagesNumbers = allPages.filter({ !fetchedPages.contains($0) }) 73 | 74 | do { 75 | let remainingList = try await delegate.loadPages(remainingPagesNumbers) 76 | 77 | await addRemainingPages(remainingList) 78 | cacheChapterCoverImage() 79 | } catch { 80 | // TODO: - need to handle this error 81 | print("Error loading remaining pages: \(error.localizedDescription)") 82 | } 83 | } 84 | } 85 | } 86 | 87 | 88 | // MARK: - PageDelegate 89 | public extension ComicPageViewModel { 90 | func nextPage() { 91 | if let currentPageInfo, currentPageInfo.pageNumber < chapter.endPage { 92 | currentPageNumber = currentPageInfo.nextPage 93 | } 94 | } 95 | 96 | func previousPage() { 97 | if currentPageNumber > chapter.startPage { 98 | currentPageNumber -= 1 99 | 100 | if currentPageInfo == nil { 101 | currentPageNumber -= 1 102 | } 103 | } 104 | } 105 | } 106 | 107 | 108 | // MARK: - MainActor 109 | @MainActor 110 | private extension ComicPageViewModel { 111 | func setPages(_ pages: [PageInfo]) { 112 | self.pages = pages 113 | self.didFetchInitialPages = true 114 | } 115 | 116 | func addRemainingPages(_ remaining: [PageInfo]) { 117 | let uniquePages = remaining.filter { newPage in 118 | !pages.contains { $0.pageNumber == newPage.pageNumber } 119 | } 120 | 121 | pages.append(contentsOf: uniquePages) 122 | pages.sort { $0.pageNumber < $1.pageNumber } 123 | } 124 | } 125 | 126 | 127 | // MARK: - Combine 128 | private extension ComicPageViewModel { 129 | func startObservers() { 130 | $currentPageNumber 131 | .sink { [unowned self] newPageNumber in 132 | delegate.updateCurrentPageNumber(newPageNumber) 133 | } 134 | .store(in: &cancellables) 135 | 136 | $didFetchInitialPages 137 | .first(where: { $0 }) 138 | .sink { [unowned self] _ in 139 | loadRemainingPages() 140 | } 141 | .store(in: &cancellables) 142 | } 143 | } 144 | 145 | 146 | // MARK: - Private Methods 147 | private extension ComicPageViewModel { 148 | func cacheChapterCoverImage() { 149 | if let chapterCoverPage = pages.first(where: { $0.pageNumber == chapter.startPage }) { 150 | delegate.saveChapterCoverPage(chapterCoverPage) 151 | } 152 | } 153 | } 154 | 155 | 156 | // MARK: - Dependencies 157 | public protocol ComicPageDelegate { 158 | func saveChapterCoverPage(_ info: PageInfo) 159 | func updateCurrentPageNumber(_ pageNumber: Int) 160 | func loadPages(_ pages: [Int]) async throws -> [PageInfo] 161 | } 162 | 163 | 164 | // MARK: - Extension Dependencies 165 | fileprivate extension Chapter { 166 | var totalPages: Int { 167 | return endPage - startPage 168 | } 169 | 170 | func getCurrentPage(currentPage: Int) -> Int { 171 | return lastReadPage ?? (containsPage(currentPage) ? currentPage : startPage) 172 | } 173 | } 174 | 175 | fileprivate extension PageInfo { 176 | var nextPage: Int { 177 | guard let secondPageNumber else { 178 | return pageNumber + 1 179 | } 180 | 181 | return secondPageNumber + 1 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/Page/ZoomableImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZoomableImageView.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ZoomableImageView: View { 11 | @State private var scale: CGFloat = 1.0 12 | @State private var lastScale: CGFloat = 1.0 13 | @State private var offset: CGSize = .zero 14 | @State private var lastOffset: CGSize = .zero 15 | 16 | let image: UIImage 17 | 18 | var body: some View { 19 | GeometryReader { geometry in 20 | Image(uiImage: image) 21 | .resizable() 22 | .scaledToFit() 23 | .scaleEffect(scale) 24 | .offset(offset) 25 | .gesture( 26 | DragGesture() 27 | .onChanged { gesture in 28 | offset = calculateBoundedOffset(translation: gesture.translation, geometrySize: geometry.size) 29 | } 30 | .onEnded { _ in 31 | lastOffset = offset 32 | } 33 | ) 34 | .gesture( 35 | MagnificationGesture() 36 | .onChanged { value in 37 | scale = min(max(lastScale * value, 1.0), 5.0) 38 | offset = calculateBoundedOffset(translation: .zero, geometrySize: geometry.size ) 39 | } 40 | .onEnded { _ in 41 | lastScale = scale 42 | } 43 | ) 44 | .gesture( 45 | TapGesture(count: 2) 46 | .onEnded { 47 | resetValues() 48 | } 49 | ) 50 | .frame(width: geometry.size.width, height: geometry.size.height) 51 | .clipped() 52 | } 53 | .edgesIgnoringSafeArea(.all) 54 | .onChange(of: image) { 55 | resetValues() 56 | } 57 | } 58 | } 59 | 60 | // MARK: - Helper Functions 61 | private extension ZoomableImageView { 62 | func resetValues() { 63 | withAnimation(.smooth) { 64 | scale = 1.0 65 | lastScale = 1.0 66 | offset = .zero 67 | lastOffset = .zero 68 | } 69 | } 70 | 71 | func calculateBoundedOffset(translation: CGSize, geometrySize: CGSize) -> CGSize { 72 | let totalWidth = geometrySize.width * scale 73 | let totalHeight = geometrySize.height * scale 74 | let maxOffsetX = max((totalWidth - geometrySize.width) / 2, 0) 75 | let maxOffsetY = max((totalHeight - geometrySize.height) / 2, 0) 76 | let boundedX = min(max(lastOffset.width + translation.width, -maxOffsetX), maxOffsetX) 77 | let boundedY = min(max(lastOffset.height + translation.height, -maxOffsetY), maxOffsetY) 78 | 79 | return .init(width: boundedX, height: boundedY) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/Shared/ComicNavStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicNavStack.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import SwiftUI 9 | import NnSwiftUIKit 10 | 11 | public struct ComicNavStack: View { 12 | @Binding var path: NavigationPath 13 | 14 | let content: () -> Content 15 | 16 | public init(path: Binding, @ViewBuilder content: @escaping () -> Content) { 17 | self._path = path 18 | self.content = content 19 | } 20 | 21 | public var body: some View { 22 | NavigationStack(path: $path) { 23 | VStack { 24 | ComicNavBar() 25 | 26 | content() 27 | } 28 | } 29 | } 30 | } 31 | 32 | public struct ComicNavBar: View { 33 | public init() { } 34 | public var body: some View { 35 | VStack(spacing: 0) { 36 | HStack { 37 | Text("Multiverse") 38 | .textLinearGradient(.yellowText) 39 | 40 | Text("Reader") 41 | .textLinearGradient(.redText) 42 | } 43 | .bold() 44 | .frame(maxWidth: .infinity, alignment: .leading) 45 | .withFont(.title3, autoSizeLineLimit: 1) 46 | 47 | Divider() 48 | } 49 | .padding() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/Shared/DisclaimerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DisclaimerView.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/10/25. 6 | // 7 | 8 | import SwiftUI 9 | import NnSwiftUIKit 10 | 11 | public struct DisclaimerView: View { 12 | let feedbackEmail = "iosdbmultiverse@gmail.com" 13 | 14 | public init() { } 15 | 16 | public var body: some View { 17 | VStack { 18 | Spacer() 19 | 20 | Text(LocalizedStringKey(.disclaimerDetails)) 21 | .withFont(.caption2) 22 | .padding() 23 | .background(.thinMaterial) 24 | .clipShape(.rect(cornerRadius: 10)) 25 | 26 | Spacer() 27 | } 28 | } 29 | } 30 | 31 | 32 | // MARK: - Dependencies 33 | extension String { 34 | static var disclaimerDetails: String { 35 | return "This is an **unofficial fan-made app** and is not affiliated with, endorsed, or sponsored by Toei Animation, Shueisha, or any official rights holders." 36 | .skipLine("Permission was granted by Salagir to distribute this app for free, but it was **not developed by the DB Multiverse team**.") 37 | .skipLine("This app is a **non-commercial project**, provided free of charge with no ads, monetization, or revenue generation.") 38 | .skipLine("All rights to the original *DB Multiverse* webcomic belong to Salagir and the *DB Multiverse* team.") 39 | .skipLine("For any questions or concerns, please contact:") 40 | .skipLine("**iosdbmultiverse@gmail.com**") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/Shared/DynamicSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DynamicSection.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import SwiftUI 9 | import NnSwiftUIKit 10 | 11 | public struct DynamicSection: View { 12 | let title: String 13 | let gradient: LinearGradient? 14 | let content: () -> Content 15 | 16 | public init(_ title: String, gradient: LinearGradient? = nil, @ViewBuilder content: @escaping () -> Content) { 17 | self.title = title 18 | self.content = content 19 | self.gradient = gradient 20 | } 21 | 22 | public var body: some View { 23 | Section { 24 | content() 25 | } header: { 26 | Text(title) 27 | .showingViewWithOptional(gradient) { gradient in 28 | Text(title) 29 | .textLinearGradient(gradient) 30 | } 31 | .withFont(.caption, autoSizeLineLimit: 1) 32 | } 33 | } 34 | } 35 | 36 | public extension View { 37 | var isPad: Bool { 38 | return UIDevice.current.userInterfaceIdiom == .pad 39 | } 40 | } 41 | 42 | enum TitleFormatter { 43 | static func formatTitle(_ title: String) -> (yellowText: String, redText: String)? { 44 | let components = title.split(separator: " ").map({ String($0) }) 45 | 46 | guard components.count > 1 else { 47 | return nil 48 | } 49 | 50 | let midIndex = components.count / 2 51 | let isEven = components.count.isMultiple(of: 2) 52 | 53 | let yellowText = components[0..<(isEven ? midIndex : midIndex + 1)].joined(separator: " ") 54 | let redText = components[(isEven ? midIndex : midIndex + 1)...].joined(separator: " ") 55 | 56 | return (yellowText, redText) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Sources/DBMultiverseComicKit/Shared/LinearGradient+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinearGradient+Extensions.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/7/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension LinearGradient { 11 | static var redText: LinearGradient { 12 | return makeTopBottomTextGradient(colors: [.red, .red.opacity(0.9), .red.opacity(0.7)]) 13 | } 14 | 15 | static var yellowText: LinearGradient { 16 | return makeTopBottomTextGradient(colors: [.yellow, .yellow, Color.yellow.opacity(0.7)]) 17 | } 18 | 19 | static var starrySky: LinearGradient { 20 | return makeTopBottomTextGradient(colors: [.init(red: 0.0, green: 0.0, blue: 0.5), .init(red: 0.0, green: 0.4, blue: 1.0)]) 21 | } 22 | 23 | static var lightStarrySky: LinearGradient { 24 | return makeTopBottomTextGradient(colors: [.init(red: 0.5, green: 0.5, blue: 0.8), .init(red: 0.7, green: 0.8, blue: 1.0), .init(red: 0.5, green: 0.5, blue: 0.8)]) 25 | } 26 | } 27 | 28 | 29 | // MARK: - Helpers 30 | fileprivate extension LinearGradient { 31 | static func makeTopBottomTextGradient(colors: [Color]) -> LinearGradient { 32 | return .init(gradient: .init(colors: colors), startPoint: .top, endPoint: .bottom) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /DBMultiverseComicKit/Tests/DBMultiverseComicKitTests/DBMultiverseComicKitTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import DBMultiverseComicKit 3 | 4 | final class DBMultiverseComicKitTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /DBMultiverseParseKit/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /DBMultiverseParseKit/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DBMultiverseParseKit", 8 | platforms: [ 9 | .iOS(.v17) 10 | ], 11 | products: [ 12 | .library( 13 | name: "DBMultiverseParseKit", 14 | targets: ["DBMultiverseParseKit"]), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "DBMultiverseParseKit", 22 | dependencies: [ 23 | "SwiftSoup" 24 | ] 25 | ), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /DBMultiverseParseKit/Sources/DBMultiverseParseKit/ComicHTMLParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicHTMLParser.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/10/25. 6 | // 7 | 8 | import SwiftSoup 9 | import Foundation 10 | 11 | public enum ComicHTMLParser { 12 | public static func parseComicPageImageSource(data: Data) throws -> String { 13 | do { 14 | let html = try makeHTML(from: data) 15 | let document = try SwiftSoup.parse(html) 16 | 17 | guard let imgElement = try document.select("img[id=balloonsimg]").first() else { 18 | throw ComicParseError.imageElementNotFound 19 | } 20 | 21 | return try imgElement.attr("src") 22 | } catch let error as ComicParseError { 23 | throw error // Re-throw the specific ComicParseError 24 | } catch { 25 | throw ComicParseError.invalidImageSource // Wrap any other error 26 | } 27 | } 28 | 29 | public static func parseChapterList(data: Data) throws -> [ParsedChapter] { 30 | do { 31 | let html = try makeHTML(from: data) 32 | let document = try SwiftSoup.parse(html) 33 | let sections = try document.select("h1.horscadrelect") 34 | 35 | var allChapters: [ParsedChapter] = [] 36 | 37 | for section in sections { 38 | let sectionTitle = try section.text() 39 | let universe = extractUniverseNumber(sectionTitle) 40 | 41 | var currentElement = try section.nextElementSibling() 42 | var currentChapters: [ParsedChapter] = [] 43 | 44 | while let element = currentElement, element.tagName() != "h1" { 45 | if element.hasClass("cadrelect") { 46 | if let chapter = try parseChapter(element, universe: universe) { 47 | currentChapters.append(chapter) 48 | } 49 | } 50 | 51 | currentElement = try element.nextElementSibling() 52 | } 53 | 54 | allChapters.append(contentsOf: currentChapters) 55 | } 56 | 57 | return allChapters 58 | } catch { 59 | throw ComicParseError.chapterListParsingFailure 60 | } 61 | } 62 | } 63 | 64 | 65 | // MARK: - Private Methods 66 | private extension ComicHTMLParser { 67 | static func makeHTML(from data: Data) throws -> String { 68 | guard let html = String(data: data, encoding: .utf8) else { 69 | throw ComicParseError.missingHTMLDocument 70 | } 71 | 72 | return html 73 | } 74 | 75 | static func extractUniverseNumber(_ title: String) -> Int? { 76 | if title.lowercased().contains("dbmultiverse") { 77 | return nil 78 | } 79 | 80 | // Regex to match any number preceded by the word "Special" (case-insensitive) 81 | let pattern = #"(?i)\bSpecial\s+Universe\s+(\d+)\b"# 82 | let regex = try? NSRegularExpression(pattern: pattern, options: []) 83 | 84 | if let match = regex?.firstMatch(in: title, options: [], range: NSRange(title.startIndex..., in: title)), 85 | let range = Range(match.range(at: 1), in: title) { 86 | // Extract and return the number 87 | return Int(title[range]) ?? Int.max 88 | } 89 | 90 | if title.lowercased().contains("broly") { 91 | return 20 92 | } 93 | 94 | return nil 95 | } 96 | 97 | static func parseChapter(_ element: Element, universe: Int?) throws -> ParsedChapter? { 98 | do { 99 | // Extract the chapter title 100 | let chapterTitle = try element.select("h4").text() 101 | 102 | // Extract the chapter number using regex 103 | let numberPattern = #"\b(\d+)\b"# 104 | let chapterNumber = chapterTitle.range(of: numberPattern, options: .regularExpression) 105 | .flatMap { Int(chapterTitle[$0]) } 106 | 107 | // Ensure chapter number exists 108 | guard let number = chapterNumber else { 109 | throw ComicParseError.invalidChapterNumber 110 | } 111 | 112 | // Extract and clean the chapter title (everything after the colon, if present) 113 | let cleanedTitle = chapterTitle 114 | .split(separator: ":", maxSplits: 1) 115 | .dropFirst() 116 | .joined(separator: ":") 117 | .trimmingCharacters(in: .whitespacesAndNewlines) 118 | 119 | // Extract page links and parse start and end pages 120 | let pageLinks = try element.select("p a") 121 | guard 122 | let startPage = try pageLinks.first().flatMap({ Int(try $0.text()) }), 123 | let endPage = try pageLinks.last().flatMap({ Int(try $0.text()) }) 124 | else { 125 | throw ComicParseError.missingPageLinks 126 | } 127 | 128 | // Extract the cover image URL 129 | let coverImageURL = try element.select("img").first()?.attr("src") ?? "" 130 | 131 | // Return the parsed chapter object 132 | return .init( 133 | name: cleanedTitle, 134 | number: number, 135 | startPage: startPage, 136 | endPage: endPage, 137 | universe: universe, 138 | coverImageURL: coverImageURL 139 | ) 140 | } catch let error as ComicParseError { 141 | throw error // Re-throw specific parsing errors 142 | } catch { 143 | throw ComicParseError.generalParsingFailure // Wrap other errors in a general parsing error 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /DBMultiverseParseKit/Sources/DBMultiverseParseKit/ComicParseError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicParseError.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/10/25. 6 | // 7 | 8 | public enum ComicParseError: Error { 9 | case missingHTMLDocument // When the HTML document is missing or invalid 10 | case missingPageLinks // When required page links are not found 11 | case invalidImageSource // When the image source cannot be extracted 12 | case generalParsingFailure // A catch-all for parsing-related failures 13 | case imageElementNotFound // When the specific image element is missing 14 | case invalidChapterNumber // When a chapter number cannot be parsed 15 | case chapterListParsingFailure // When the entire chapter list fails to parse 16 | } 17 | -------------------------------------------------------------------------------- /DBMultiverseParseKit/Sources/DBMultiverseParseKit/ParsedChapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParsedChapter.swift 3 | // 4 | // 5 | // Created by Nikolai Nobadi on 1/10/25. 6 | // 7 | 8 | public struct ParsedChapter { 9 | public let name: String 10 | public let number: Int 11 | public let startPage: Int 12 | public let endPage: Int 13 | public let universe: Int? 14 | public let coverImageURL: String 15 | 16 | public init(name: String, number: Int, startPage: Int, endPage: Int, universe: Int?, coverImageURL: String) { 17 | self.name = name 18 | self.number = number 19 | self.startPage = startPage 20 | self.endPage = endPage 21 | self.universe = universe 22 | self.coverImageURL = coverImageURL 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /DBMultiverseUnitTests/TestPlan/UnitTestPlan.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "1B49BBB1-2133-47FC-B4CF-59B0BDE590FC", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : { 13 | "targets" : [ 14 | { 15 | "containerPath" : "container:DBMultiverse.xcodeproj", 16 | "identifier" : "E8A095B82CE0941200D28E3F", 17 | "name" : "DBMultiverse" 18 | }, 19 | { 20 | "containerPath" : "container:DBMultiverseComicKit", 21 | "identifier" : "DBMultiverseComicKit", 22 | "name" : "DBMultiverseComicKit" 23 | } 24 | ] 25 | }, 26 | "testExecutionOrdering" : "random", 27 | "testTimeoutsEnabled" : true 28 | }, 29 | "testTargets" : [ 30 | { 31 | "target" : { 32 | "containerPath" : "container:DBMultiverse.xcodeproj", 33 | "identifier" : "D46C706D2D1BB4C000C217D6", 34 | "name" : "DBMultiverseUnitTests" 35 | } 36 | }, 37 | { 38 | "target" : { 39 | "containerPath" : "container:DBMultiverseComicKit", 40 | "identifier" : "DBMultiverseComicKitTests", 41 | "name" : "DBMultiverseComicKitTests" 42 | } 43 | } 44 | ], 45 | "version" : 1 46 | } 47 | -------------------------------------------------------------------------------- /DBMultiverseUnitTests/UnitTests/ComicPageManagerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicPageManagerTests.swift 3 | // DBMultiverseUnitTests 4 | // 5 | // Created by Nikolai Nobadi on 1/10/25. 6 | // 7 | 8 | import XCTest 9 | import NnTestHelpers 10 | import DBMultiverseComicKit 11 | @testable import DBMultiverse 12 | 13 | final class ComicPageManagerTests: XCTestCase { 14 | func test_starting_values_are_empty() { 15 | let (_, delegate) = makeSUT() 16 | 17 | XCTAssertNil(delegate.savedMetadata) 18 | XCTAssertNil(delegate.lastPageReadInfo) 19 | XCTAssertNil(delegate.readProgressInfo) 20 | XCTAssertNil(delegate.chapterMarkedAsRead) 21 | XCTAssert(delegate.savedPageInfoList.isEmpty) 22 | } 23 | 24 | func test_saves_cover_image_with_metadata_containing_chapter_name_and_progress() { 25 | let chapter = makeChapter(name: "Test Chapter", startPage: 1, endPage: 20) 26 | let pageInfo = PageInfo(chapter: chapter.number, pageNumber: 10, secondPageNumber: nil, imageData: Data("image".utf8)) 27 | let (sut, delegate) = makeSUT(chapter: chapter) 28 | 29 | sut.saveChapterCoverPage(pageInfo) 30 | 31 | assertPropertyEquality(delegate.savedMetadata?.chapterName, expectedProperty: chapter.name) 32 | assertPropertyEquality(delegate.savedMetadata?.chapterNumber, expectedProperty: chapter.number) 33 | assertPropertyEquality(delegate.savedMetadata?.readProgress, expectedProperty: 50) 34 | } 35 | 36 | func test_loads_cached_pages_and_fetches_missing_pages_from_network() async throws { 37 | let cachedPageInfo = makePageInfo(page: 2) 38 | let chapter = makeChapter(startPage: 1, endPage: 20) 39 | let (sut, delegate) = makeSUT(chapter: chapter, cachedPages: [cachedPageInfo]) 40 | let pages = try await sut.loadPages([2, 3]) 41 | 42 | assertArray(pages.map(\.pageNumber), contains: [2, 3]) 43 | assertArray(delegate.savedPageInfoList.map(\.pageNumber), contains: [3]) 44 | assertArray(delegate.savedPageInfoList.map(\.pageNumber), doesNotContain: [2]) 45 | } 46 | 47 | func test_does_not_mark_chapter_as_complete_when_end_page_is_not_read() { 48 | let start = 1 49 | let end = 5 50 | let chapter = makeChapter(startPage: start, endPage: end) 51 | 52 | for page in start.. (sut: ComicPageManager, delegate: MockDelegate) { 75 | let delegate = MockDelegate(throwError: throwError, cachedPages: cachedPages) 76 | let sut = ComicPageManager(chapter: chapter ?? makeChapter(), language: .english, imageCache: delegate, networkService: delegate, chapterProgressHandler: delegate) 77 | 78 | trackForMemoryLeaks(sut, file: file, line: line) 79 | trackForMemoryLeaks(delegate, file: file, line: line) 80 | 81 | return (sut, delegate) 82 | } 83 | 84 | func makePageInfo(page: Int, secondPage: Int? = nil) -> PageInfo { 85 | return .init(chapter: 1, pageNumber: page, secondPageNumber: secondPage, imageData: .init()) 86 | } 87 | } 88 | 89 | 90 | // MARK: - Helper Classes 91 | class MockDelegate { 92 | private let throwError: Bool 93 | private var cachedPages: [PageInfo] 94 | private(set) var savedPageInfoList: [PageInfo] = [] 95 | private(set) var chapterMarkedAsRead: Chapter? 96 | private(set) var lastPageReadInfo: (Int, Chapter)? 97 | private(set) var readProgressInfo: (Int, Int)? 98 | private(set) var savedMetadata: CoverImageMetaData? 99 | 100 | init(throwError: Bool, cachedPages: [PageInfo]) { 101 | self.throwError = throwError 102 | self.cachedPages = cachedPages 103 | } 104 | } 105 | 106 | extension MockDelegate: ComicPageNetworkService { 107 | func fetchImageData(from url: URL?) async throws -> Data { 108 | if throwError { throw NSError(domain: "Test", code: 0) } 109 | 110 | return .init() 111 | } 112 | } 113 | 114 | extension MockDelegate: ChapterProgressHandler { 115 | func markChapterAsRead(_ chapter: Chapter) { 116 | chapterMarkedAsRead = chapter 117 | } 118 | 119 | func updateLastReadPage(page: Int, chapter: Chapter) { 120 | lastPageReadInfo = (page, chapter) 121 | } 122 | } 123 | 124 | extension MockDelegate: ComicImageCache { 125 | func savePageImage(pageInfo: PageInfo) throws { 126 | if throwError { throw NSError(domain: "Test", code: 0) } 127 | 128 | savedPageInfoList.append(pageInfo) 129 | } 130 | 131 | func loadCachedImage(chapter: Int, page: Int) throws -> PageInfo? { 132 | if throwError { throw NSError(domain: "Test", code: 0) } 133 | 134 | return cachedPages.popLast() 135 | } 136 | 137 | func updateCurrentPageNumber(_ pageNumber: Int, readProgress: Int) { 138 | readProgressInfo = (pageNumber, readProgress) 139 | } 140 | 141 | func saveChapterCoverImage(imageData: Data, metadata: CoverImageMetaData) throws { 142 | if throwError { throw NSError(domain: "Test", code: 0) } 143 | 144 | savedMetadata = metadata 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /DBMultiverseUnitTests/UnitTests/MainFeaturesViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainFeaturesViewModelTests.swift 3 | // DBMultiverseUnitTests 4 | // 5 | // Created by Nikolai Nobadi on 1/10/25. 6 | // 7 | 8 | import XCTest 9 | import Combine 10 | import NnTestHelpers 11 | import DBMultiverseComicKit 12 | @testable import DBMultiverse 13 | 14 | final class MainFeaturesViewModelTests: XCTestCase { 15 | private var cancellables = Set() 16 | 17 | override func tearDown() { 18 | cancellables.forEach { $0.cancel() } 19 | cancellables.removeAll() 20 | super.tearDown() 21 | } 22 | } 23 | 24 | 25 | // MARK: - Unit Tests 26 | extension MainFeaturesViewModelTests { 27 | func test_starting_values_are_empty() { 28 | let (sut, loader) = makeSUT() 29 | 30 | XCTAssertNil(loader.urlPath) 31 | XCTAssert(sut.chapters.isEmpty) 32 | XCTAssertNil(sut.nextChapterToRead) 33 | XCTAssertEqual(sut.lastReadMainStoryPage, 0) 34 | XCTAssertNotEqual(sut.lastReadSpecialPage, sut.lastReadMainStoryPage) 35 | } 36 | 37 | func test_url_contains_correct_language_parameter() async { 38 | for language in ComicLanguage.allCases { 39 | let (sut, loader) = makeSUT() 40 | 41 | await asyncAssertNoErrorThrown { 42 | try await sut.loadData(language: language) 43 | } 44 | 45 | assertProperty(loader.urlPath) { path in 46 | XCTAssert(path.contains(language.rawValue)) 47 | } 48 | } 49 | } 50 | 51 | func test_loads_and_sets_chapters_correctly() async { 52 | let chaptersToLoad = [makeChapter(name: "Chapter 1"), makeChapter(name: "Chapter 2")] 53 | let sut = makeSUT(chaptersToLoad: chaptersToLoad).sut 54 | 55 | await asyncAssertNoErrorThrown { 56 | try await sut.loadData(language: .english) 57 | } 58 | 59 | assertArray(sut.chapters, contains: chaptersToLoad) 60 | } 61 | 62 | func test_updates_current_page_number_based_on_comic_type() { 63 | let (sut, _) = makeSUT() 64 | 65 | sut.updateCurrentPageNumber(42, comicType: .story) 66 | XCTAssertEqual(sut.lastReadMainStoryPage, 42) 67 | XCTAssertNotEqual(sut.lastReadSpecialPage, 42) 68 | 69 | sut.updateCurrentPageNumber(99, comicType: .specials) 70 | XCTAssertEqual(sut.lastReadSpecialPage, 99) 71 | XCTAssertNotEqual(sut.lastReadMainStoryPage, 99) 72 | } 73 | 74 | func test_returns_current_page_number_based_on_comic_type() { 75 | let (sut, _) = makeSUT() 76 | 77 | sut.updateCurrentPageNumber(21, comicType: .story) 78 | XCTAssertEqual(sut.getCurrentPageNumber(for: .story), 21) 79 | XCTAssertEqual(sut.getCurrentPageNumber(for: .specials), sut.lastReadSpecialPage) 80 | 81 | sut.updateCurrentPageNumber(84, comicType: .specials) 82 | XCTAssertEqual(sut.getCurrentPageNumber(for: .specials), 84) 83 | XCTAssertEqual(sut.getCurrentPageNumber(for: .story), 21) 84 | } 85 | 86 | func test_sets_next_chapter_to_read() { 87 | let (sut, _) = makeSUT() 88 | let chapter = makeChapter(name: "Next Chapter") 89 | 90 | sut.startNextChapter(chapter) 91 | 92 | assertPropertyEquality(sut.nextChapterToRead, expectedProperty: chapter) 93 | } 94 | } 95 | 96 | 97 | // MARK: - SUT 98 | extension MainFeaturesViewModelTests { 99 | func makeSUT(throwError: Bool = false, chaptersToLoad: [Chapter] = [], file: StaticString = #filePath, line: UInt = #line) -> (sut: MainFeaturesViewModel, loader: MockLoader) { 100 | let defaults = makeTestDefaults() 101 | let loader = MockLoader(throwError: throwError, chaptersToLoad: chaptersToLoad) 102 | let sut = MainFeaturesViewModel(loader: loader, userDefaults: defaults) 103 | 104 | trackForMemoryLeaks(sut, file: file, line: line) 105 | trackForMemoryLeaks(loader, file: file, line: line) 106 | 107 | return (sut, loader) 108 | } 109 | 110 | func makeTestDefaults(name: String = "testSuite") -> UserDefaults? { 111 | let defaults = UserDefaults(suiteName: name) 112 | defaults?.removePersistentDomain(forName: name) 113 | return defaults 114 | } 115 | } 116 | 117 | 118 | // MARK: - Helper Classes 119 | extension MainFeaturesViewModelTests { 120 | class MockLoader: ChapterLoader { 121 | private let throwError: Bool 122 | private let chaptersToLoad: [Chapter] 123 | private(set) var urlPath: String? 124 | 125 | init(throwError: Bool, chaptersToLoad: [Chapter]) { 126 | self.throwError = throwError 127 | self.chaptersToLoad = chaptersToLoad 128 | } 129 | 130 | func loadChapters(url: URL?) async throws -> [Chapter] { 131 | if throwError { throw NSError(domain: "Test", code: 0) } 132 | 133 | urlPath = url?.path 134 | 135 | return chaptersToLoad 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /DBMultiverseUnitTests/UnitTests/XCTestCase+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTestCase+Extensions.swift 3 | // DBMultiverseUnitTests 4 | // 5 | // Created by Nikolai Nobadi on 1/10/25. 6 | // 7 | 8 | import XCTest 9 | import DBMultiverseComicKit 10 | 11 | extension XCTestCase { 12 | func makeChapter(name: String = "first", startPage: Int = 0, endPage: Int = 20) -> Chapter { 13 | return .init(name: name, number: 1, startPage: startPage, endPage: endPage, universe: nil, lastReadPage: nil, coverImageURL: "", didFinishReading: false) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Resources/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Resources/Assets.xcassets/sampleCoverImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "dbmSampleImage.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Resources/Assets.xcassets/sampleCoverImage.imageset/dbmSampleImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolainobadi/DBMultiverse/3cc87a1e6f310334bfbdda4f720edf71e1ef3366/DBMultiverseWidgets/Resources/Assets.xcassets/sampleCoverImage.imageset/dbmSampleImage.png -------------------------------------------------------------------------------- /DBMultiverseWidgets/Resources/DBMultiverseWidgetsExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.com.nobadi.dbm 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionPointIdentifier 8 | com.apple.widgetkit-extension 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Sources/Main/DBMultiverseWidgetsBundle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DBMultiverseWidgetsBundle.swift 3 | // DBMultiverseWidgets 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | @main 12 | struct DBMultiverseWidgetsBundle: WidgetBundle { 13 | var body: some Widget { 14 | DBMultiverseWidgets() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Sources/Widget/ComicImageEntry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicImageEntry.swift 3 | // DBMultiverseWidgetsExtension 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | struct ComicImageEntry: TimelineEntry { 12 | let date: Date 13 | let chapter: Int 14 | let name: String 15 | let progress: Int 16 | let image: Image? 17 | let family: WidgetFamily 18 | let deepLink: URL 19 | } 20 | 21 | extension ComicImageEntry { 22 | static func makeSample(family: WidgetFamily) -> ComicImageEntry { 23 | return .init(date: .now, chapter: 1, name: "A Really Strange Tournament!", progress: 0, image: .init("sampleCoverImage"), family: family, deepLink: .sampleURL) 24 | } 25 | } 26 | 27 | extension URL { 28 | static var sampleURL: URL { 29 | return .init(string: "dbmultiverse://chapter/1")! 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Sources/Widget/DBMultiverseWidgets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DBMultiverseWidgets.swift 3 | // DBMultiverseWidgets 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | import DBMultiverseComicKit 11 | 12 | struct DBMultiverseWidgets: Widget { 13 | let kind: String = "DBMultiverseWidgets" 14 | 15 | var body: some WidgetConfiguration { 16 | StaticConfiguration(kind: kind, provider: Provider()) { entry in 17 | DBMultiverseWidgetContentView(entry: entry) 18 | .containerBackground(LinearGradient.starrySky, for: .widget) 19 | } 20 | .configurationDisplayName("DBMultiverse Widget") 21 | .description("Quickly jump back into the action where you last left off.") 22 | .supportedFamilies(UIDevice.current.userInterfaceIdiom == .pad ? [.systemMedium] : [.systemSmall]) 23 | } 24 | } 25 | 26 | 27 | // MARK: - ContentView 28 | fileprivate struct DBMultiverseWidgetContentView: View { 29 | let entry: ComicImageEntry 30 | 31 | var body: some View { 32 | if entry.family == .systemSmall { 33 | SmallWidgetView(entry: entry) 34 | } else { 35 | MediumWidgetView(entry: entry) 36 | } 37 | } 38 | } 39 | 40 | 41 | 42 | // MARK: - Preview 43 | #Preview(as: .systemSmall) { 44 | DBMultiverseWidgets() 45 | } timeline: { 46 | ComicImageEntry.makeSample(family: .systemSmall) 47 | } 48 | //#Preview(as: .systemMedium) { 49 | // DBMultiverseWidgets() 50 | //} timeline: { 51 | // ComicImageEntry.makeSample(family: .systemMedium) 52 | //} 53 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Sources/Widget/Provider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Provider.swift 3 | // DBMultiverseWidgetsExtension 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | import DBMultiverseComicKit 11 | 12 | struct Provider: TimelineProvider { 13 | func placeholder(in context: Context) -> ComicImageEntry { 14 | return .makeSample(family: context.family) 15 | } 16 | 17 | func getSnapshot(in context: Context, completion: @escaping (ComicImageEntry) -> Void) { 18 | guard let chapterData = CoverImageCache.shared.loadCurrentChapterData() else { 19 | completion(.init(date: .now, chapter: 0, name: "", progress: 0, image: nil, family: context.family, deepLink: .sampleURL)) 20 | return 21 | } 22 | 23 | let progress = chapterData.progress 24 | let image = makeImage(path: chapterData.coverImagePath) 25 | 26 | completion(.init(date: .now, chapter: chapterData.number, name: chapterData.name, progress: progress, image: image, family: context.family, deepLink: .sampleURL)) 27 | } 28 | 29 | func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { 30 | guard let chapterData = CoverImageCache.shared.loadCurrentChapterData() else { 31 | completion(.init(entries: [.init(date: .now, chapter: 0, name: "", progress: 0, image: nil, family: context.family, deepLink: .sampleURL)], policy: .atEnd)) 32 | return 33 | } 34 | 35 | let progress = chapterData.progress 36 | let image = makeImage(path: chapterData.coverImagePath) 37 | let deepLink = URL(string: "dbmultiverse://chapter/\(chapterData.number)")! 38 | let entry = ComicImageEntry(date: .now, chapter: chapterData.number, name: chapterData.name, progress: progress, image: image, family: context.family, deepLink: deepLink) 39 | 40 | completion(.init(entries: [entry], policy: .atEnd)) 41 | } 42 | } 43 | 44 | 45 | // MARK: - private Methods 46 | private extension Provider { 47 | func makeImage(path: String?) -> Image? { 48 | guard let path, let uiImage = UIImage(contentsOfFile: path) else { 49 | return nil 50 | } 51 | 52 | return .init(uiImage: uiImage) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Sources/WidgetViews/MediumWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediumWidgetView.swift 3 | // DBMultiverseWidgetsExtension 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | import DBMultiverseComicKit 11 | 12 | struct MediumWidgetView: View { 13 | let entry: ComicImageEntry 14 | 15 | var body: some View { 16 | Link(destination: entry.deepLink) { 17 | HStack { 18 | if let image = entry.image { 19 | image 20 | .resizable() 21 | .aspectRatio(contentMode: .fit) 22 | } 23 | 24 | Spacer() 25 | 26 | HStack { 27 | VStack { 28 | HStack { 29 | HStack { 30 | Text("Ch") 31 | .textLinearGradient(.yellowText) 32 | Text("\(entry.chapter)") 33 | .textLinearGradient(.redText) 34 | } 35 | .withFont() 36 | } 37 | .bold() 38 | .padding(.bottom, 5) 39 | .font(.title2) 40 | 41 | Text(entry.name) 42 | .padding(.horizontal) 43 | .multilineTextAlignment(.center) 44 | .withFont(.caption, textColor: .white, autoSizeLineLimit: 2) 45 | } 46 | 47 | Text("\(entry.progress)%") 48 | .withFont(textColor: .white) 49 | } 50 | } 51 | } 52 | .showingConditionalView(when: entry.chapter == 0) { 53 | HStack { 54 | Image("sampleCoverImage") 55 | .resizable() 56 | .aspectRatio(contentMode: .fit) 57 | .padding(.horizontal) 58 | Spacer() 59 | VStack { 60 | Text("Read") 61 | .withFont(.caption, textColor: .white, autoSizeLineLimit: 1) 62 | 63 | HStack { 64 | Text("Multiverse") 65 | .textLinearGradient(.yellowText) 66 | Text("Reader") 67 | .textLinearGradient(.redText) 68 | } 69 | .withFont(autoSizeLineLimit: 1) 70 | } 71 | } 72 | } 73 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) 74 | } 75 | } 76 | 77 | 78 | // MARK: - Preview 79 | #Preview(as: .systemMedium) { 80 | DBMultiverseWidgets() 81 | } timeline: { 82 | ComicImageEntry.makeSample(family: .systemMedium) 83 | } 84 | -------------------------------------------------------------------------------- /DBMultiverseWidgets/Sources/WidgetViews/SmallWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SmallWidgetView.swift 3 | // DBMultiverseWidgetsExtension 4 | // 5 | // Created by Nikolai Nobadi on 1/8/25. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | import DBMultiverseComicKit 11 | 12 | struct SmallWidgetView: View { 13 | let entry: ComicImageEntry 14 | 15 | var body: some View { 16 | VStack(spacing: 0) { 17 | HStack { 18 | HStack { 19 | Text("Ch") 20 | .textLinearGradient(.yellowText) 21 | Text("\(entry.chapter)") 22 | .textLinearGradient(.redText) 23 | } 24 | .withFont() 25 | 26 | Text("\(entry.progress)%") 27 | .bold() 28 | .withFont(.caption2, textColor: .white, autoSizeLineLimit: 1) 29 | } 30 | .bold() 31 | .padding(.bottom, 5) 32 | .font(.title2) 33 | 34 | if let image = entry.image { 35 | image 36 | .resizable() 37 | .frame(width: 70, height: 90) 38 | } 39 | } 40 | .showingConditionalView(when: entry.chapter == 0) { 41 | VStack { 42 | Text("Read") 43 | .padding(5) 44 | .withFont(.caption2, textColor: .white) 45 | Text("Multiverse") 46 | .textLinearGradient(.yellowText) 47 | Text("Reader") 48 | .textLinearGradient(.redText) 49 | } 50 | .withFont(autoSizeLineLimit: 1) 51 | } 52 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) 53 | } 54 | } 55 | 56 | 57 | // MARK: - Preview 58 | #Preview(as: .systemSmall) { 59 | DBMultiverseWidgets() 60 | } timeline: { 61 | ComicImageEntry.makeSample(family: .systemSmall) 62 | } 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nikolai Nobadi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Multiverse Reader 3 | 4 | Multiverse Reader is an iOS application designed to enhance the experience of reading and managing the DB Multiverse webcomic. The app integrates several modules, each providing specific functionality to ensure a seamless and enjoyable user experience. 5 | 6 | ## TestFlight 7 | If you just want access to the app, it has been approved for beta testing through TestFlight (Apple's own beta testing app). 8 | 9 | [Install Multiverse Reader with TestFlight](https://testflight.apple.com/join/8B21HpTS) 10 | 11 | If you don't have TestFlight installed on your device, the link should first prompt you to install TestFlight, then you should be able to install Multiverse Reader. 12 | 13 | ## Table of Contents 14 | 15 | - [Overview](#overview) 16 | - [Screenshots](#screenshots) 17 | - [iPhone Screenshots](#iphone-screenshots) 18 | - [iPad Screenshots](#ipad-screenshots) 19 | - [Installation](docs/XcodeInstallation.md) 20 | - [Modules](#modules) 21 | - [DBMultiverse](docs/DBMultiverse_Documentation.md) 22 | - [ComicKit](docs/DBMultiverseComicKit_Documentation.md) 23 | - [ParseKit](docs/DBMultiverseParseKit_Documentation.md) 24 | - [Widgets](docs/DBMultiverseWidgets_Documentation.md) 25 | 26 | - [License](LICENSE) 27 | 28 | ## Overview 29 | 30 | Multiverse Reader is built with modularity in mind, utilizing distinct modules for: 31 | - Parsing webcomic data from HTML sources (ParseKit). 32 | - Managing and displaying comic chapters and pages (ComicKit). 33 | - Extending functionality via home screen widgets (Widgets). 34 | 35 | Each module is documented in detail, and their integration is explained within the core [DBMultiverse Documentation](docs/DBMultiverse_Documentation.md). 36 | 37 | ## Screenshots 38 | 39 | ### iPhone Screenshots 40 | (The name of the app changed. I'll get around to updating the screenshots soon.) 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 |
Chapter ListComic View
iPhone Chapter ListiPhone Comic View
52 | 53 | ### iPad Screenshots 54 | (The name of the app changed. I'll get around to updating the screenshots soon.) 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
Chapter ListComic View
iPad Chapter ListiPad Comic View
66 | 67 | ## Installation 68 | 69 | To install the app using Xcode: 70 | 71 | 1. **Open** Xcode (or **Download** it from the [Mac App Store](https://apps.apple.com/us/app/xcode/id497799835?mt=12)). 72 | 2. **Clone the project** from the [GitHub repository](https://github.com/nikolainobadi/DBMultiverse). 73 | 3. **Open the project** in Xcode by selecting the `DBMultiverse.xcodeproj` file. 74 | 4. **Connect your iPhone or iPad** and select it as the target device in Xcode. 75 | 5. **Run the app** by clicking the play button in Xcode. 76 | 77 | For detailed installation steps, refer to the [Xcode Installation Guide](docs/XcodeInstallation.md). 78 | 79 | ## Modules 80 | 81 | The **DBMultiverse** app is architected with modularity at its core, leveraging the separation of concerns to ensure scalability, maintainability, and ease of testing. Each module has a clearly defined purpose: 82 | 83 | ### 1. Core Application 84 | - **Documentation**: [MultiverseReader](docs/DBMultiverse_Documentation.md) 85 | - **Purpose**: Acts as the central layer that integrates functionality from all modules, orchestrating the primary app logic and navigation. 86 | 87 | ### 2. ComicKit Module 88 | - **Documentation**: [ComicKit](docs/DBMultiverseComicKit_Documentation.md) 89 | - **Purpose**: Manages comic-related functionality, including chapter handling, caching, and displaying comic pages with interactive features. 90 | 91 | ### 3. ParseKit Module 92 | - **Documentation**: [ParseKit](docs/DBMultiverseParseKit_Documentation.md) 93 | - **Purpose**: Responsible for parsing HTML data to extract comic metadata dynamically, enabling updates and ensuring accurate content delivery. 94 | 95 | ### 4. Widgets Module 96 | - **Documentation**: [Widgets](docs/DBMultiverseWidgets_Documentation.md) 97 | - **Purpose**: Extends the app’s functionality to the home screen, providing widgets that display chapter progress and enable quick navigation. 98 | 99 | ### Architecture and Benefits 100 | The **DBMultiverse** app’s modular architecture provides numerous benefits: 101 | - **Reduced Coupling**: Each module operates independently, allowing easier updates and maintenance without impacting other parts of the app. 102 | - **Scalability**: Adding new features or expanding existing ones is simplified due to the modular structure. 103 | - **Ease of Testing**: Modules can be tested in isolation, ensuring robust functionality and easier debugging. 104 | - **Code Reuse**: Core components, such as **ComicKit**, can be reused across different projects. 105 | 106 | The modules fit together seamlessly: 107 | - **ParseKit** supplies structured data to **ComicKit**, which processes and presents it. 108 | - The core **DBMultiverse** app integrates these features to provide the main user experience. 109 | - **Widgets** consume data from **ComicKit** to deliver dynamic and interactive home screen functionality. 110 | 111 | ## License 112 | 113 | This project is licensed under the terms specified in the [LICENSE](LICENSE) file. 114 | -------------------------------------------------------------------------------- /docs/DBMultiverseComicKit_Documentation.md: -------------------------------------------------------------------------------- 1 | 2 | # ComicKit Module Documentation 3 | 4 | ## Overview 5 | 6 | The **ComicKit** module is a core component of the **DBMultiverse** project. It provides tools for managing and displaying comic chapters, pages, and related metadata. It includes models, utilities, UI components, and caching mechanisms to enhance user experience in reading comics. 7 | 8 | ## Features 9 | 10 | ### 1. **Chapter Management** 11 | - **Structures:** 12 | - `Chapter`: Represents a comic chapter with metadata such as name, number, and page range. 13 | - **Protocols:** 14 | - `ChapterListEventHandler`: Defines methods for interacting with chapter lists, such as toggling read status or retrieving sections. 15 | 16 | ### 2. **Page Management** 17 | - **Structures:** 18 | - `PageInfo`: Stores information about a comic page, including image data and page numbers. 19 | - **Classes:** 20 | - `ComicPageViewModel`: A view model for managing the state and actions related to comic pages. 21 | - **Protocols:** 22 | - `ComicPageDelegate`: Defines methods for saving and updating page-related information. 23 | 24 | ### 3. **Caching** 25 | - **Structures:** 26 | - `CoverImageMetaData`: Metadata for caching chapter cover images. 27 | - **Classes:** 28 | - `CoverImageCache`: Manages caching of chapter cover images and progress data. 29 | 30 | ### 4. **Custom UI Components** 31 | - `ZoomableImageView`: Provides a pinch-to-zoom interface for images. 32 | - `ComicPageImageView`: Displays a single comic page with its associated metadata. 33 | - `DynamicSection`: A reusable section component with optional gradients for headers. 34 | - `ComicNavStack`: A navigation stack for comic-specific content. 35 | 36 | ## Dependencies 37 | 38 | The **ComicKit** module relies on the following dependencies: 39 | 40 | - **NnSwiftUIKit**: Provides utilities and extensions for SwiftUI, such as gradients, reusable components, and view modifiers. 41 | 42 | ## Public Interfaces 43 | 44 | ### 1. Models 45 | #### `Chapter` 46 | Represents a comic chapter. 47 | - **Properties:** 48 | - `name`: The chapter's name. 49 | - `number`: The chapter's number. 50 | - `startPage`, `endPage`: Page range. 51 | - `universe`: Associated universe (optional). 52 | - `lastReadPage`: Last read page (optional). 53 | - `coverImageURL`: URL for the chapter cover image. 54 | - `didFinishReading`: Whether the chapter is marked as read. 55 | 56 | #### `PageInfo` 57 | Represents information about a comic page. 58 | - **Properties:** 59 | - `chapter`: Chapter number. 60 | - `pageNumber`: Page number. 61 | - `secondPageNumber`: Optional second page number. 62 | - `imageData`: Image data for the page. 63 | 64 | ### 2. UI Components 65 | #### `ZoomableImageView` 66 | Provides zoom and pan functionality for images. 67 | 68 | #### `ComicPageImageView` 69 | Displays a comic page with zoom functionality. 70 | 71 | #### `DynamicSection` 72 | A reusable section with a customizable gradient and dynamic section header font sizing. 73 | -------------------------------------------------------------------------------- /docs/DBMultiverseParseKit_Documentation.md: -------------------------------------------------------------------------------- 1 | 2 | # DBMultiverseParseKit Documentation 3 | 4 | ## Overview 5 | The **DBMultiverseParseKit** module is responsible for parsing HTML content and extracting the necessary data for the DBMultiverse app. It serves as the backbone for data extraction, ensuring the app can dynamically fetch and process chapter information and comic images from the web. 6 | 7 | ## Features 8 | - Extracts chapter details, including chapter number, name, page range, and cover image. 9 | - Parses comic page HTML to fetch image sources. 10 | - Handles errors with custom error types for detailed debugging. 11 | 12 | ## Components 13 | 14 | ### **ComicHTMLParser** 15 | A utility for parsing comic-related HTML data. 16 | 17 | #### Key Methods: 18 | - `parseComicPageImageSource(data: Data) throws -> String`: 19 | Extracts the `src` attribute of the comic page image from the provided HTML data. 20 | - **Throws**: `ComicParseError` in case of failure. 21 | 22 | - `parseChapterList(data: Data) throws -> [ParsedChapter]`: 23 | Extracts a list of chapters from the provided HTML data. 24 | - **Throws**: `ComicParseError` in case of failure. 25 | 26 | ### **ParsedChapter** 27 | A model that represents a parsed chapter from the HTML data. 28 | 29 | ## Error Handling 30 | The module utilizes `ComicParseError` for detailed error descriptions, enabling easier debugging and reliable error management. 31 | 32 | ## Dependencies 33 | - **SwiftSoup**: A Swift-based HTML parser library for navigating and extracting data from HTML documents. 34 | - **Foundation**: For general Swift utilities like `Data` and `URL`. 35 | 36 | ## Usage Example 37 | ```swift 38 | let htmlData: Data = ... // Fetch HTML data 39 | do { 40 | let chapterList = try ComicHTMLParser.parseChapterList(data: htmlData) 41 | print(chapterList) 42 | } catch let error as ComicParseError { 43 | print("Parsing failed with error: \(error)") 44 | } 45 | ``` -------------------------------------------------------------------------------- /docs/DBMultiverseWidgets_Documentation.md: -------------------------------------------------------------------------------- 1 | 2 | # DBMultiverseWidgets Documentation 3 | 4 | ## Overview 5 | The **DBMultiverseWidgets** module provides a seamless way for users to interact with the DBMultiverse app directly from their home screen. The widget displays the chapter currently being read, as well as the current 'read' progress. The widget defaults to the small size for iPhone and the medium size for iPad. 6 | 7 | ## Features 8 | - Display the current or next comic chapter with progress tracking. 9 | - Visual representation of cover images. 10 | - Dynamic deep links to quickly navigate to specific chapters. 11 | - Conditional views for both small and medium widget sizes. 12 | 13 | ### **ComicImageEntry** 14 | A model conforming to `TimelineEntry` that represents the widget’s data. 15 | 16 | #### Properties: 17 | - `date: Date`: The timestamp of the entry. 18 | - `chapter: Int`: The current chapter number. 19 | - `name: String`: The chapter name. 20 | - `progress: Int`: The reading progress percentage. 21 | - `image: Image?`: The chapter’s cover image. 22 | - `family: WidgetFamily`: The widget family (small or medium). 23 | - `deepLink: URL`: The deep link to navigate to the chapter. 24 | 25 | ## How It Works 26 | 1. **Data Handling**: 27 | - Data is fetched from the app’s `CoverImageCache`, a dependency imported from the shared module `DBMultiverseComicKit`. 28 | - Chapter progress and cover images are dynamically retrieved. 29 | 30 | 2. **Dynamic View Content**: 31 | - Displays relevant chapter data if available. 32 | - Defaults to a placeholder view if no data is cached. 33 | 34 | ## Dependencies 35 | - **DBMultiverseComicKit**: Supplies core models and data for widget entries. 36 | - **SwiftUI** and **WidgetKit**: Core frameworks for UI and widget functionality. 37 | -------------------------------------------------------------------------------- /docs/DBMultiverse_Documentation.md: -------------------------------------------------------------------------------- 1 | 2 | # DBMultiverse Documentation 3 | 4 | ## Overview 5 | 6 | The **DBMultiverse** target serves as the core of the application, integrating functionality from the other modules (`DBMultiverseComicKit`, `DBMultiverseParseKit`, and `DBMultiverseWidgets`) to deliver the primary user experience. This target defines the main app logic, user interface, and navigation. 7 | 8 | ## Key Components 9 | 10 | ### `LaunchView.swift` 11 | - **Purpose**: Entry point for the app. 12 | - **Features**: 13 | - Displays the `WelcomeView` for first-time users. 14 | - Transitions to `MainFeaturesView` after the initial setup. 15 | - **Dependencies**: 16 | - `ComicLanguage` for language preferences. 17 | - `ChapterLoaderAdapter` for chapter loading. 18 | 19 | ### `WelcomeView.swift` 20 | - **Purpose**: Guides new users through initial setup. 21 | - **Features**: 22 | - Language selection. 23 | - Disclaimer and introduction text. 24 | - Smooth animations for transitioning between states. 25 | - **Dependencies**: 26 | - `LanguagePicker` for language selection. 27 | - `DisclaimerView` for displaying terms. 28 | 29 | ### `SettingsFeatureNavStack.swift` 30 | - **Purpose**: Provides navigation for the settings feature. 31 | - **Features**: 32 | - Dynamically loads cached chapters. 33 | - Allows clearing cache and updating language preferences. 34 | - Links to external resources (e.g., Authors, Help pages). 35 | - **Dependencies**: 36 | - `SettingsViewModel` for business logic. 37 | - `SettingsFormView`, `LanguageSelectionView`, and `CacheChapterListView` for specific settings UI. 38 | 39 | ### `MainFeaturesView.swift` 40 | - **Purpose**: Core view that displays chapters and comics. 41 | - **Features**: 42 | - Navigation between comic pages and settings. 43 | - Synchronizes data with SwiftData. 44 | - Handles deep linking. 45 | - **Dependencies**: 46 | - `SwiftDataChapterList` for chapter storage. 47 | - `ChapterLoaderAdapter` for loading chapters. 48 | 49 | ### `iPhoneMainTabView.swift` and `iPadMainNavStack.swift` 50 | - **Purpose**: Provide navigation stacks for different devices. 51 | - **Features**: 52 | - Tab-based navigation on iPhone. 53 | - Navigation stack with settings integration on iPad. 54 | - **Dependencies**: 55 | - `ComicNavStack` for managing comic navigation. 56 | 57 | ### `ComicPageFeatureView.swift` 58 | - **Purpose**: Displays individual comic pages. 59 | - **Features**: 60 | - Handles navigation between pages. 61 | - Fetches and caches comic page images. 62 | - Updates reading progress. 63 | - **Dependencies**: 64 | - `ComicPageViewModel` for handling page data. 65 | - `ComicPageManager` for business logic. 66 | 67 | ## Core ViewModels and Managers 68 | 69 | ### `MainFeaturesViewModel` 70 | - **Purpose**: Central ViewModel managing chapters and user progress. 71 | - **Key Functions**: 72 | - `loadData(language:)`: Fetches chapter data for the specified language. 73 | - `updateCurrentPageNumber(_:comicType:)`: Updates the user's current page. 74 | - `startNextChapter(_:)`: Prepares the next chapter for reading. 75 | - **Dependencies**: 76 | - `ChapterLoader` for fetching chapter data. 77 | - `UserDefaults` for storing progress via `AppStorage`. 78 | 79 | ### `ComicPageManager` 80 | - **Purpose**: Manages fetching, caching, and displaying comic pages. 81 | - **Key Functions**: 82 | - `loadPages(_:)`: Fetches comic pages from cache or network. 83 | - `updateCurrentPageNumber(_:)`: Updates reading progress. 84 | - `saveChapterCoverPage(_:)`: Saves cover image metadata. 85 | - **Dependencies**: 86 | - `ComicImageCache` for caching. 87 | - `ComicPageNetworkService` for fetching images. 88 | - `ChapterProgressHandler` for tracking progress. 89 | 90 | ## ViewModifiers 91 | 92 | ### `DeepLinkNavigationViewModifier` 93 | - **Purpose**: Enables navigation via deep links. 94 | - **Features**: 95 | - Parses URLs to navigate directly to specific chapters. 96 | 97 | ### `SwiftDataChapterStorageViewModifier` 98 | - **Purpose**: Synchronizes `Chapter` objects with SwiftData storage. 99 | - **Features**: 100 | - Automatically updates chapters in the database when new data is available. 101 | 102 | ## Utilities 103 | 104 | ### `URLFactory` 105 | - **Purpose**: Constructs URLs for fetching data using the base url for the website and the selected language. 106 | 107 | ### Persistence with SwiftData 108 | - The app uses `SwiftDataChapter` as the primary model for storing chapter data. 109 | - Data is synchronized automatically via view modifiers. -------------------------------------------------------------------------------- /docs/XcodeInstallation.md: -------------------------------------------------------------------------------- 1 | 2 | # Xcode Installation Guide 3 | 4 | This guide will walk you through installing the app on your iPhone or iPad using Xcode. Don't worry if you've never used Xcode or GitHub before—each step is explained in detail. 5 | 6 | --- 7 | 8 | ## Step 1: Download Xcode 9 | 10 | 1. Open the [Mac App Store](https://apps.apple.com/us/app/xcode/id497799835?mt=12) on your Mac. 11 | 2. Search for **Xcode**. 12 | 3. Click **Get** or **Install** to download and install Xcode (it’s free). 13 | 14 | --- 15 | 16 | ## Step 2: Clone the Project 17 | 18 | ### Option 1: Download ZIP (Simple Method) 19 | 20 | 1. Navigate to the project repository: [DBMultiverse on GitHub](https://github.com/nikolainobadi/DBMultiverse). 21 | 2. Click the green **Code** button and select **Download ZIP**. 22 | 3. Locate the downloaded `.zip` file in your Downloads folder and double-click it to extract the contents. 23 | 24 | ### Option 2: Use Git Command Line (Advanced) 25 | 26 | If you're comfortable using the Terminal, follow these steps to clone the repository directly: 27 | 28 | 1. Open **Terminal** (search for "Terminal" in Spotlight). 29 | 2. Check if Git is installed by running: 30 | ```bash 31 | git --version 32 | ``` 33 | If Git is not installed, download it from [git-scm.com](https://git-scm.com). 34 | 35 | 3. Navigate to your Desktop (or another folder where you want to save the project): 36 | ```bash 37 | cd ~/Desktop 38 | ``` 39 | 40 | 4. Clone the repository: 41 | ```bash 42 | git clone https://github.com/nikolainobadi/DBMultiverse.git 43 | ``` 44 | 45 | 5. This will create a `DBMultiverse` folder in your chosen directory. 46 | 47 | --- 48 | 49 | ## Step 3: Open the Project in Xcode 50 | 51 | 1. Open Xcode. 52 | 2. Click **File > Open...** from the menu bar. 53 | 3. Navigate to the extracted or cloned project folder and select `DBMultiverse.xcodeproj`. 54 | 55 | --- 56 | 57 | ## Step 4: Pair Your iPhone or iPad 58 | 59 | 1. Connect your iPhone or iPad to your Mac using a USB cable. 60 | 2. In Xcode, click on the **Device Selector** near the top left of the window (it looks like a play button with a dropdown next to it). 61 | 3. Select your connected device from the list. If prompted, follow the on-screen instructions to pair your device and enable **Developer Mode**: 62 | - Go to **Settings > Privacy & Security** on your device and toggle **Developer Mode**. 63 | 64 | --- 65 | 66 | ## Step 5: Run the App 67 | 68 | 1. In Xcode, click the **Run** button (a triangle-shaped play icon) at the top left of the window. 69 | 2. Xcode will build the app and install it on your device. 70 | 3. Once the app launches on your device, you can start exploring DragonBall Multiverse through the app! 71 | 72 | --- 73 | 74 | ## Notes on Code Signing 75 | 76 | To install the DBMultiverse app on your device, you will need to 'sign the app' using your own Apple Developer Team, which you can do with your Apple ID. 77 | 78 | ### Free Apple ID 79 | 80 | - Apps signed with a free Apple ID will expire after **7 days**, requiring reinstallation via Xcode. 81 | 82 | ### Paid Apple Developer Program 83 | 84 | - Apps signed with a paid Apple Developer account will not expire after 7 days. 85 | 86 | ### How to Select Your Developer Team in Xcode 87 | 88 | 1. Click on the project name (the blue icon) in the Project Navigator. 89 | 2. Open the **Signing & Capabilities** tab. 90 | 3. Under **Team**, select your Apple ID or Developer Program team. 91 | 4. If your Apple ID isn't listed, click **Add an Account** and log in with your credentials. 92 | -------------------------------------------------------------------------------- /media/appIcon.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolainobadi/DBMultiverse/3cc87a1e6f310334bfbdda4f720edf71e1ef3366/media/appIcon.jpeg -------------------------------------------------------------------------------- /media/ipad_chapterList.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolainobadi/DBMultiverse/3cc87a1e6f310334bfbdda4f720edf71e1ef3366/media/ipad_chapterList.png -------------------------------------------------------------------------------- /media/ipad_comicView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolainobadi/DBMultiverse/3cc87a1e6f310334bfbdda4f720edf71e1ef3366/media/ipad_comicView.png -------------------------------------------------------------------------------- /media/iphone_chapterList.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolainobadi/DBMultiverse/3cc87a1e6f310334bfbdda4f720edf71e1ef3366/media/iphone_chapterList.png -------------------------------------------------------------------------------- /media/iphone_comicView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolainobadi/DBMultiverse/3cc87a1e6f310334bfbdda4f720edf71e1ef3366/media/iphone_comicView.png --------------------------------------------------------------------------------