├── .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 | Chapter List |
45 | Comic View |
46 |
47 |
48 |  |
49 |  |
50 |
51 |
52 |
53 | ### iPad Screenshots
54 | (The name of the app changed. I'll get around to updating the screenshots soon.)
55 |
56 |
57 |
58 | Chapter List |
59 | Comic View |
60 |
61 |
62 |  |
63 |  |
64 |
65 |
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
--------------------------------------------------------------------------------