From 2793ad8cfa5e27186b17cf1b814c6fdf1f86b3b4 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 8 Mar 2026 15:30:43 +0500 Subject: [PATCH] =?UTF-8?q?iOS:=20codebase=20cleanup=20=E2=80=94=20remove?= =?UTF-8?q?=20dead=20code,=20unify=20design,=20deduplicate=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove swift-markdown-ui dependency (project.yml, pbxproj, Package.resolved) - Remove all debug print() statements (APIClient, BrowseViewModel, ChapterReaderViewModel, ChapterReaderView) - Remove dead UI stubs: isFavorited/isLiked state, heart/star buttons, empty Share/Add to Library menu items in FullPlayerView - Remove unused audioToolbarButton toolbar builder in ChapterReaderView - Remove unused model types: NovelListing, BookDetailData, ChapterContent - Remove amberLight color (never referenced) - Remove mini player interactive seek (horizontal drag/tap to seek) — progress bar is now display-only - Fix AccentColor asset to amber #f59e0b (was orange-pink mismatch) - Fix Discover tab icon: globe → globe.americas.fill - Fix speed slider max: 3.0 → 2.0 in ProfileView - Fix yearText: use static DateFormatter instead of allocating on every access - Fix ChangePasswordView.save(): DispatchQueue.main.asyncAfter → Task.sleep - Deduplicate navigationDestination blocks via .appNavigationDestination() View extension - Deduplicate .alert(Error) pattern via .errorAlert() View extension --- .../LibNovel.xcodeproj/project.pbxproj | 17 -- .../xcshareddata/swiftpm/Package.resolved | 27 -- ios/LibNovel/LibNovel/App/RootTabView.swift | 2 +- .../LibNovel/Extensions/Color+App.swift | 1 - .../LibNovel/Extensions/NavDestination.swift | 28 ++ .../LibNovel/Extensions/String+App.swift | 19 +- ios/LibNovel/LibNovel/Models/Models.swift | 40 --- .../LibNovel/Networking/APIClient.swift | 35 +-- .../AccentColor.colorset/Contents.json | 2 +- .../LibNovel/ViewModels/BrowseViewModel.swift | 3 - .../ViewModels/ChapterReaderViewModel.swift | 16 +- .../Views/BookDetail/BookDetailView.swift | 4 +- .../LibNovel/Views/Browse/BrowseView.swift | 7 +- .../ChapterReader/ChapterReaderView.swift | 76 ++--- .../LibNovel/Views/Home/HomeView.swift | 13 +- .../LibNovel/Views/Library/LibraryView.swift | 11 +- .../LibNovel/Views/Player/PlayerViews.swift | 270 ++++++------------ .../LibNovel/Views/Profile/ProfileView.swift | 9 +- ios/LibNovel/project.yml | 5 - 19 files changed, 183 insertions(+), 402 deletions(-) diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj index d7e5c03..cb97b2d 100644 --- a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj +++ b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj @@ -24,7 +24,6 @@ 94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; }; 9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; }; A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; }; - BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 6313AB4B3A5464F647791174 /* MarkdownUI */; }; BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; }; C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; }; CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; }; @@ -86,7 +85,6 @@ buildActionMask = 2147483647; files = ( CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */, - BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -327,7 +325,6 @@ name = LibNovel; packageProductDependencies = ( 09584EAB68A07B47F876A062 /* Kingfisher */, - 6313AB4B3A5464F647791174 /* MarkdownUI */, ); productName = LibNovel; productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */; @@ -353,7 +350,6 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */, - C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */; @@ -670,14 +666,6 @@ minimumVersion = 8.0.0; }; }; - C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.4.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -686,11 +674,6 @@ package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; - 6313AB4B3A5464F647791174 /* MarkdownUI */ = { - isa = XCSwiftPackageProductDependency; - package = C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; - productName = MarkdownUI; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A10A669C0C8B43078C0FEE9F /* Project object */; diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0c3d28f..6fa1928 100644 --- a/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/LibNovel/LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,33 +9,6 @@ "revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008", "version" : "8.8.0" } - }, - { - "identity" : "networkimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/NetworkImage", - "state" : { - "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", - "version" : "6.0.1" - } - }, - { - "identity" : "swift-cmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-cmark", - "state" : { - "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", - "version" : "0.7.1" - } - }, - { - "identity" : "swift-markdown-ui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swift-markdown-ui", - "state" : { - "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", - "version" : "2.4.1" - } } ], "version" : 3 diff --git a/ios/LibNovel/LibNovel/App/RootTabView.swift b/ios/LibNovel/LibNovel/App/RootTabView.swift index 37143d0..673752c 100644 --- a/ios/LibNovel/LibNovel/App/RootTabView.swift +++ b/ios/LibNovel/LibNovel/App/RootTabView.swift @@ -31,7 +31,7 @@ struct RootTabView: View { .tag(Tab.library) BrowseView() - .tabItem { Label("Discover", systemImage: "globe") } + .tabItem { Label("Discover", systemImage: "globe.americas.fill") } .tag(Tab.browse) ProfileView() diff --git a/ios/LibNovel/LibNovel/Extensions/Color+App.swift b/ios/LibNovel/LibNovel/Extensions/Color+App.swift index 96d5aa4..7f4fe40 100644 --- a/ios/LibNovel/LibNovel/Extensions/Color+App.swift +++ b/ios/LibNovel/LibNovel/Extensions/Color+App.swift @@ -3,7 +3,6 @@ import SwiftUI // MARK: - App accent color (amber — mirrors Tailwind amber-500 #f59e0b) extension Color { static let amber = Color(red: 0.96, green: 0.62, blue: 0.04) - static let amberLight = Color(red: 1.0, green: 0.84, blue: 0.40) } extension ShapeStyle where Self == Color { diff --git a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift index 49a70f6..ac441ab 100644 --- a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift +++ b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift @@ -6,3 +6,31 @@ enum NavDestination: Hashable { case book(String) // slug case chapter(String, Int) // slug + chapter number } + +// MARK: - View extensions for shared navigation + error alert patterns + +extension View { + /// Registers the app-wide navigation destinations for NavDestination values. + /// Apply once per NavigationStack instead of repeating the switch in every tab. + func appNavigationDestination() -> some View { + navigationDestination(for: NavDestination.self) { dest in + switch dest { + case .book(let slug): BookDetailView(slug: slug) + case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) + } + } + } + + /// Presents a standard "Error" alert driven by an optional String binding. + /// Dismissing the alert sets the binding back to nil. + func errorAlert(_ error: Binding) -> some View { + alert("Error", isPresented: Binding( + get: { error.wrappedValue != nil }, + set: { if !$0 { error.wrappedValue = nil } } + )) { + Button("OK") { error.wrappedValue = nil } + } message: { + Text(error.wrappedValue ?? "") + } + } +} diff --git a/ios/LibNovel/LibNovel/Extensions/String+App.swift b/ios/LibNovel/LibNovel/Extensions/String+App.swift index 18b0352..2d2f179 100644 --- a/ios/LibNovel/LibNovel/Extensions/String+App.swift +++ b/ios/LibNovel/LibNovel/Extensions/String+App.swift @@ -3,18 +3,28 @@ import Foundation // MARK: - String helpers for display purposes extension String { - /// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago") - /// that may be embedded in chapter titles coming from older scraped records. + /// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago", + /// or "(One)4 years ago" where the number is attached without a preceding space). func strippingTrailingDate() -> String { let units = ["second", "minute", "hour", "day", "week", "month", "year"] let lower = self.lowercased() for unit in units { for suffix in [unit + "s ago", unit + " ago"] { guard let suffixRange = lower.range(of: suffix, options: .backwards) else { continue } - // Walk backwards past whitespace to find the numeric token + // Everything before the suffix let before = String(self[self.startIndex ..< suffixRange.lowerBound]) let trimmed = before.trimmingCharacters(in: .whitespaces) - // Find start of last word (should be a number) + // Strip trailing digits (the numeric count, which may be attached without a space) + var result = trimmed + while let last = result.last, last.isNumber { + result.removeLast() + } + result = result.trimmingCharacters(in: .whitespaces) + if result != trimmed { + // We actually stripped some digits — return cleaned result + return result + } + // Fallback: number preceded by space if let spaceIdx = trimmed.lastIndex(of: " ") { let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...]) if Int(potentialNum) != nil { @@ -22,7 +32,6 @@ extension String { .trimmingCharacters(in: .whitespaces) } } else if Int(trimmed) != nil { - // The entire string is just a number + "ago" return "" } } diff --git a/ios/LibNovel/LibNovel/Models/Models.swift b/ios/LibNovel/LibNovel/Models/Models.swift index de73c85..c659ea1 100644 --- a/ios/LibNovel/LibNovel/Models/Models.swift +++ b/ios/LibNovel/LibNovel/Models/Models.swift @@ -126,22 +126,6 @@ struct AppUser: Codable, Identifiable { } } -// MARK: - Browse / Novel Listing - -struct NovelListing: Identifiable, Hashable { - var id: String { slug.isEmpty ? url : slug } - let slug: String - let title: String - let cover: String - let rank: String - let rating: String - let chapters: String - let url: String - let author: String - let status: String - let genres: [String] -} - // MARK: - Ranking struct RankingItem: Codable, Identifiable { @@ -201,17 +185,6 @@ struct UserSession: Codable, Identifiable { } } -// MARK: - Book Detail - -struct BookDetailData { - let book: Book - let chapters: [ChapterIndex] - let previewChapters: [PreviewChapter]? - let inLib: Bool - let saved: Bool - let lastChapter: Int? -} - struct PreviewChapter: Codable, Identifiable { var id: Int { number } let number: Int @@ -219,19 +192,6 @@ struct PreviewChapter: Codable, Identifiable { let url: String } -// MARK: - Chapter Content - -struct ChapterContent { - let book: BookBrief - let chapter: ChapterIndex - let html: String - let voices: [String] - let prev: Int? - let next: Int? - let chapters: [ChapterIndexBrief] - let isPreview: Bool -} - struct BookBrief: Codable { let slug: String let title: String diff --git a/ios/LibNovel/LibNovel/Networking/APIClient.swift b/ios/LibNovel/LibNovel/Networking/APIClient.swift index a5b00e4..9985757 100644 --- a/ios/LibNovel/LibNovel/Networking/APIClient.swift +++ b/ios/LibNovel/LibNovel/Networking/APIClient.swift @@ -63,9 +63,7 @@ actor APIClient { // paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly. let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - print("[APIClient] ▶ \(method) \(urlString)") guard let url = URL(string: urlString) else { - print("[APIClient] ✗ Could not construct URL from: \(urlString)") throw APIError.invalidResponse } var req = URLRequest(url: url) @@ -75,12 +73,6 @@ actor APIClient { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONEncoder().encode(body) } - // Log cookies being sent - if let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty { - print("[APIClient] cookies: \(cookies.map { "\($0.name)=\($0.value.prefix(20))…" })") - } else { - print("[APIClient] cookies: (none)") - } return req } @@ -90,40 +82,15 @@ actor APIClient { let req = try makeRequest(path, method: method, body: body) let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { - print("[APIClient] ✗ \(path) — not an HTTP response") throw APIError.invalidResponse } let rawBody = String(data: data, encoding: .utf8) ?? "" - print("[APIClient] ◀ \(http.statusCode) \(path) — \(data.count) bytes") - if data.count < 2000 { - print("[APIClient] body: \(rawBody)") - } else { - print("[APIClient] body (first 2000 chars): \(rawBody.prefix(2000))") - } guard (200..<300).contains(http.statusCode) else { throw APIError.httpError(http.statusCode, rawBody) } do { - let decoded = try JSONDecoder.iso8601.decode(T.self, from: data) - print("[APIClient] ✓ decoded \(T.self) from \(path)") - return decoded + return try JSONDecoder.iso8601.decode(T.self, from: data) } catch { - print("[APIClient] ✗ decode error for \(T.self) from \(path)") - print("[APIClient] decode error: \(error)") - if let decodingError = error as? DecodingError { - switch decodingError { - case .typeMismatch(let type, let ctx): - print("[APIClient] typeMismatch: expected \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))") - case .valueNotFound(let type, let ctx): - print("[APIClient] valueNotFound: \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))") - case .keyNotFound(let key, let ctx): - print("[APIClient] keyNotFound: '\(key.stringValue)' at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))") - case .dataCorrupted(let ctx): - print("[APIClient] dataCorrupted: \(ctx.debugDescription) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))") - @unknown default: - print("[APIClient] unknown decoding error: \(error)") - } - } throw APIError.decodingError(error) } } diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json index e66f937..e2be29f 100644 --- a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -3,7 +3,7 @@ { "color": { "color-space": "srgb", - "components": { "alpha": "1.000", "blue": "0.588", "green": "0.467", "red": "1.000" } + "components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" } }, "idiom": "universal" } diff --git a/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift index 75d3717..558c7a1 100644 --- a/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift +++ b/ios/LibNovel/LibNovel/ViewModels/BrowseViewModel.swift @@ -50,14 +50,12 @@ final class BrowseViewModel: ObservableObject { } private func loadPage(_ page: Int) async { - print("[Browse] loadPage(\(page)) genre=\(genre) sort=\(sort) status=\(status)") isLoading = true error = nil do { let result = try await APIClient.shared.browse( page: page, genre: genre, sort: sort, status: status ) - print("[Browse] ✓ page \(page) — \(result.novels.count) novels, hasNext=\(result.hasNext)") if page == 1 { novels = result.novels } else { @@ -67,7 +65,6 @@ final class BrowseViewModel: ObservableObject { currentPage = page } catch { if !(error is CancellationError) { - print("[Browse] ✗ error on page \(page): \(error)") self.error = error.localizedDescription } } diff --git a/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift index 00e4fd5..599c050 100644 --- a/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift +++ b/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift @@ -3,34 +3,38 @@ import Foundation @MainActor final class ChapterReaderViewModel: ObservableObject { let slug: String - let chapter: Int + private(set) var chapter: Int @Published var content: ChapterResponse? @Published var isLoading = false @Published var error: String? - @Published var navigateTo: Int? // set to trigger navigation to next chapter init(slug: String, chapter: Int) { self.slug = slug self.chapter = chapter } + /// Switch to a different chapter in-place: resets state and updates `chapter` + /// so that `.task(id: currentChapter)` in the View re-fires `load()`. + func switchChapter(to newChapter: Int) { + guard newChapter != chapter else { return } + chapter = newChapter + content = nil + error = nil + } + func load() async { - print("[ChapterReader] load() slug=\(slug) chapter=\(chapter)") isLoading = true error = nil do { content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter) - print("[ChapterReader] ✓ loaded chapter \(chapter), html length=\(content?.html.count ?? 0)") // Record reading progress try? await APIClient.shared.setProgress(slug: slug, chapter: chapter) } catch { if !(error is CancellationError) { - print("[ChapterReader] ✗ error: \(error)") self.error = error.localizedDescription } } - print("[ChapterReader] done — isLoading=false, content=\(content != nil), error=\(String(describing: self.error))") isLoading = false } diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift index 8dd8637..bfe5c14 100644 --- a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift +++ b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift @@ -30,9 +30,7 @@ struct BookDetailView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { bookmarkButton } .task { await vm.load() } - .alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) { - Button("OK") { vm.error = nil } - } message: { Text(vm.error ?? "") } + .errorAlert($vm.error) } // MARK: - Hero diff --git a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift index 765701e..50fb046 100644 --- a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift +++ b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift @@ -90,12 +90,7 @@ struct BrowseView: View { } } .navigationTitle("Discover") - .navigationDestination(for: NavDestination.self) { dest in - switch dest { - case .book(let slug): BookDetailView(slug: slug) - case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) - } - } + .appNavigationDestination() .sheet(isPresented: $showFilters) { BrowseFiltersView(vm: vm) } diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift index 3935c71..e017c08 100644 --- a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift @@ -7,6 +7,9 @@ struct ChapterReaderView: View { let slug: String let chapterNumber: Int + /// Tracks the currently displayed chapter — updated in-place by skip/auto-next + /// so we never accumulate stale listeners on the navigation stack. + @State private var currentChapter: Int @StateObject private var vm: ChapterReaderViewModel @EnvironmentObject var audioPlayer: AudioPlayerService @EnvironmentObject var authStore: AuthStore @@ -14,20 +17,17 @@ struct ChapterReaderView: View { init(slug: String, chapterNumber: Int) { self.slug = slug self.chapterNumber = chapterNumber + _currentChapter = State(initialValue: chapterNumber) _vm = StateObject(wrappedValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber)) } var body: some View { - let _ = print("[ChapterReaderView] body eval — slug=\(slug) ch=\(chapterNumber) isLoading=\(vm.isLoading) hasContent=\(vm.content != nil) error=\(vm.error ?? "nil")") Group { if vm.isLoading { - let _ = print("[ChapterReaderView] branch: LOADING") ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) } else if let content = vm.content { - let _ = print("[ChapterReaderView] branch: CONTENT html=\(content.html.count)chars title='\(content.chapter.title)'") readerContent(content) } else if let errMsg = vm.error { - let _ = print("[ChapterReaderView] branch: ERROR '\(errMsg)'") VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle") .font(.largeTitle) @@ -42,7 +42,6 @@ struct ChapterReaderView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - let _ = print("[ChapterReaderView] branch: BLANK (no loading, no content, no error)") Color.clear } } @@ -54,43 +53,42 @@ struct ChapterReaderView: View { floatingAudioButton } } - .task { - print("[ChapterReaderView] .task fired — calling vm.load()") + .task(id: currentChapter) { await vm.load() - print("[ChapterReaderView] .task completed") } .onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in guard let next = note.userInfo?["next"] as? Int else { return } let shouldAutoNavigate = note.userInfo?["autoNext"] as? Bool ?? false - if shouldAutoNavigate { - vm.navigateTo = next - } + // Only handle if this is the top-most (currently active) chapter view + guard shouldAutoNavigate, currentChapter == audioPlayer.chapter else { return } + navigateToChapter(next) } .onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in guard let next = note.userInfo?["next"] as? Int else { return } - vm.navigateTo = next + // Only the view whose chapter matches the currently playing chapter should handle this + guard currentChapter == audioPlayer.chapter else { return } + navigateToChapter(next) } .onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in guard let prev = note.userInfo?["prev"] as? Int else { return } - vm.navigateTo = prev - } - .navigationDestination(isPresented: Binding( - get: { vm.navigateTo != nil }, - set: { if !$0 { vm.navigateTo = nil } } - )) { - if let next = vm.navigateTo { - ChapterReaderView(slug: slug, chapterNumber: next) - } + guard currentChapter == audioPlayer.chapter else { return } + navigateToChapter(prev) } } + /// Navigate to a chapter in-place: reloads content without pushing to the navigation stack. + /// Back button always returns to BookDetailView regardless of how many chapters were visited. + private func navigateToChapter(_ chapter: Int) { + vm.switchChapter(to: chapter) + currentChapter = chapter + } + // MARK: - Content @State private var webHeight: CGFloat = 800 @ViewBuilder private func readerContent(_ content: ChapterResponse) -> some View { - let _ = print("[ChapterReaderView] readerContent — html=\(content.html.count)chars webHeight=\(webHeight)") ScrollView { VStack(alignment: .leading, spacing: 16) { // Header @@ -114,17 +112,21 @@ struct ChapterReaderView: View { Divider() - // Prev / Next navigation + // Prev / Next navigation — in-place swap so back button always returns to book HStack(spacing: 12) { if let prev = content.prev { - NavigationLink(value: NavDestination.chapter(slug, prev)) { + Button { + navigateToChapter(prev) + } label: { Label("Ch.\(prev)", systemImage: "chevron.left") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } if let next = content.next { - NavigationLink(value: NavDestination.chapter(slug, next)) { + Button { + navigateToChapter(next) + } label: { Label("Ch.\(next)", systemImage: "chevron.right") .labelStyle(ReverseLabelStyle()) .frame(maxWidth: .infinity) @@ -171,23 +173,6 @@ struct ChapterReaderView: View { .padding(.trailing, 20) .padding(.bottom, 20) } - - // MARK: - Audio toolbar button (kept for when player is active) - - @ToolbarContentBuilder - private var audioToolbarButton: some ToolbarContent { - ToolbarItem(placement: .topBarTrailing) { - Button { - vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings) - } label: { - Image(systemName: audioPlayer.isActive && - audioPlayer.slug == slug && - audioPlayer.chapter == chapterNumber - ? "speaker.wave.2.fill" : "speaker.wave.2") - .foregroundStyle(.amber) - } - } - } } // MARK: - HTML content renderer using WKWebView @@ -224,7 +209,6 @@ struct HTMLContentView: UIViewRepresentable { p { margin: 0 0 1em 0; } """ let wrapped = "\(html)" - print("[HTMLContentView] updateUIView — html=\(html.count)chars loading into WKWebView") uiView.loadHTMLString(wrapped, baseURL: nil) } @@ -233,18 +217,12 @@ struct HTMLContentView: UIViewRepresentable { init(_ parent: HTMLContentView) { self.parent = parent } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - print("[HTMLContentView] didFinish — evaluating scrollHeight") webView.evaluateJavaScript("document.body.scrollHeight") { result, error in DispatchQueue.main.async { - print("[HTMLContentView] scrollHeight result=\(String(describing: result)) error=\(String(describing: error))") if let h = result as? CGFloat, h > 0 { - print("[HTMLContentView] height set to CGFloat \(h)") self.parent.height = h } else if let h = result as? Double, h > 0 { - print("[HTMLContentView] height set to Double \(h)") self.parent.height = CGFloat(h) - } else { - print("[HTMLContentView] ⚠️ could not read height — keeping \(self.parent.height)") } } } diff --git a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift index 9fa6770..022b8a1 100644 --- a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift +++ b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift @@ -75,19 +75,10 @@ struct HomeView: View { .padding(.vertical) } .navigationTitle("Home") - .navigationDestination(for: NavDestination.self) { dest in - switch dest { - case .book(let slug): BookDetailView(slug: slug) - case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) - } - } + .appNavigationDestination() .refreshable { await vm.load() } .task { await vm.load() } - .alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) { - Button("OK") { vm.error = nil } - } message: { - Text(vm.error ?? "") - } + .errorAlert($vm.error) } } } diff --git a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift index 1efd2e4..3b6016f 100644 --- a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift +++ b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift @@ -34,17 +34,10 @@ struct LibraryView: View { } } .navigationTitle("Library") - .navigationDestination(for: NavDestination.self) { dest in - switch dest { - case .book(let slug): BookDetailView(slug: slug) - case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) - } - } + .appNavigationDestination() .refreshable { await vm.load() } .task { await vm.load() } - .alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) { - Button("OK") { vm.error = nil } - } message: { Text(vm.error ?? "") } + .errorAlert($vm.error) } } } diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index 3458f14..eafc701 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -10,42 +10,23 @@ struct MiniPlayerView: View { /// Live drag offset while the user is swiping up/down (negative = moving up). @State private var dragOffset: CGFloat = 0 - /// Track horizontal drag for seek gesture - @State private var seekDragOffset: CGFloat = 0 - @State private var isSeeking: Bool = false var body: some View { ZStack { - // Interactive progress bar as background (full bleed behind content) + // Static progress bar as background (full bleed behind content) GeometryReader { geo in ZStack(alignment: .leading) { // Background track - full pill shape RoundedRectangle(cornerRadius: 40) .fill(Color.white.opacity(0.2)) - + // Progress fill - rounded to match pill shape RoundedRectangle(cornerRadius: 40) .fill(Color.amber.opacity(0.3)) - .frame(width: max(0, geo.size.width * (isSeeking ? seekProgress : progress))) + .frame(width: max(0, geo.size.width * progress)) } - .contentShape(Rectangle()) - .highPriorityGesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - isSeeking = true - let ratio = max(0, min(1, value.location.x / geo.size.width)) - seekDragOffset = ratio - } - .onEnded { value in - let ratio = max(0, min(1, value.location.x / geo.size.width)) - let newTime = audioPlayer.duration * ratio - audioPlayer.seek(to: newTime) - isSeeking = false - seekDragOffset = 0 - } - ) } - + // Content layer HStack(spacing: 16) { // Cover thumbnail with rounded corners @@ -165,8 +146,6 @@ struct MiniPlayerView: View { } .padding(.horizontal, 20) .padding(.vertical, 12) - // Block interaction with content layer from affecting progress bar - .allowsHitTesting(true) } .background( // Dark rounded background (pill-shaped with full circular ends) @@ -230,10 +209,6 @@ struct MiniPlayerView: View { guard audioPlayer.duration > 0 else { return 0 } return CGFloat(audioPlayer.currentTime / audioPlayer.duration) } - - private var seekProgress: CGFloat { - return seekDragOffset - } private var chapterLabel: String { let raw = audioPlayer.chapterTitle.isEmpty @@ -250,8 +225,6 @@ struct FullPlayerView: View { /// Called when the view wants to close itself (Done button or drag-to-dismiss). var onDismiss: () -> Void = {} - @State private var isFavorited = false - @State private var isLiked = false @State private var showingSpeedMenu = false @State private var showingChaptersList = false @State private var showingSleepTimer = false @@ -278,9 +251,7 @@ struct FullPlayerView: View { .fill(Color.white.opacity(0.35)) .frame(width: 36, height: 4) .padding(.top, 12) - .padding(.bottom, 8) - - Spacer(minLength: 0) + .padding(.bottom, 16) // Cover art with watermark ZStack(alignment: .bottomLeading) { @@ -296,23 +267,21 @@ struct FullPlayerView: View { ) } .scaledToFill() - .frame(width: 260, height: 260) + .frame(width: 240, height: 240) .clipShape(RoundedRectangle(cornerRadius: 18)) .shadow(color: .black.opacity(0.5), radius: 24, y: 12) // Watermark (voice name from audio player) Text(voiceName) - .font(.custom("Snell Roundhand", size: 22)) + .font(.custom("Snell Roundhand", size: 20)) .foregroundStyle(.white.opacity(0.7)) .shadow(color: .black.opacity(0.4), radius: 2) - .padding(16) + .padding(14) } .padding(.horizontal, 48) - Spacer(minLength: 0) - // Title block - VStack(spacing: 6) { + VStack(spacing: 4) { Text((audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle).strippingTrailingDate()) .font(.title3.weight(.bold)) .foregroundStyle(.white) @@ -324,51 +293,21 @@ struct FullPlayerView: View { .lineLimit(1) } .padding(.horizontal, 32) - .padding(.top, 28) - - // Action buttons row (like, favorite, menu) - HStack(spacing: 32) { + .padding(.top, 20) + + // Action buttons row + metadata inline + HStack(spacing: 0) { Spacer() - Button { - withAnimation(.spring(response: 0.3)) { - isLiked.toggle() - } - } label: { - Image(systemName: isLiked ? "heart.fill" : "heart") - .font(.system(size: 26)) - .foregroundStyle(isLiked ? .pink : .white.opacity(0.7)) - .frame(width: 44, height: 44) + // Metadata pill (only when ready) + if audioPlayer.status != .generating { + Text("\(yearText) · \(cacheStatusText) · OPUS") + .font(.caption2) + .foregroundStyle(.white.opacity(0.35)) + .padding(.horizontal, 8) } - .buttonStyle(.plain) - - Button { - withAnimation(.spring(response: 0.3)) { - isFavorited.toggle() - } - } label: { - Image(systemName: isFavorited ? "star.fill" : "star") - .font(.system(size: 26)) - .foregroundStyle(isFavorited ? .amber : .white.opacity(0.7)) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) Menu { - Button { - // Share - } label: { - Label("Share", systemImage: "square.and.arrow.up") - } - - Button { - // Add to library - } label: { - Label("Add to Library", systemImage: "plus") - } - - Divider() - Button { audioPlayer.autoNext.toggle() } label: { @@ -379,80 +318,62 @@ struct FullPlayerView: View { } } label: { Image(systemName: "ellipsis.circle") - .font(.system(size: 26)) - .foregroundStyle(.white.opacity(0.7)) - .frame(width: 44, height: 44) + .font(.system(size: 22)) + .foregroundStyle(.white.opacity(0.6)) + .frame(width: 40, height: 40) } .buttonStyle(.plain) Spacer() } - .padding(.top, 12) - - // Metadata row (year, cache status, format) - HStack(spacing: 8) { - Text(yearText) - .font(.caption) - .foregroundStyle(.white.opacity(0.4)) - - if audioPlayer.status != .generating { - Text("•") - .font(.caption) - .foregroundStyle(.white.opacity(0.3)) - - Text(cacheStatusText) - .font(.caption) - .foregroundStyle(.white.opacity(0.4)) - - Text("•") - .font(.caption) - .foregroundStyle(.white.opacity(0.3)) - - Text("OPUS") - .font(.caption) - .foregroundStyle(.white.opacity(0.4)) - - Text("•") - .font(.caption) - .foregroundStyle(.white.opacity(0.3)) - - Text("149 kbps") - .font(.caption) - .foregroundStyle(.white.opacity(0.4)) - } - } - .padding(.top, 4) + .padding(.top, 10) - // Seek bar - VStack(spacing: 6) { - PlayerSlider( - value: Binding( - get: { audioPlayer.currentTime }, - set: { audioPlayer.seek(to: $0) } - ), - range: 0...max(audioPlayer.duration, 1) - ) - HStack { - Text(formatTime(audioPlayer.currentTime)) - Spacer() - Text("-" + formatTime(audioPlayer.duration - audioPlayer.currentTime)) + // Seek bar — hidden while generating + if audioPlayer.status != .generating { + VStack(spacing: 4) { + PlayerSlider( + value: Binding( + get: { audioPlayer.currentTime }, + set: { audioPlayer.seek(to: $0) } + ), + range: 0...max(audioPlayer.duration, 1) + ) + HStack { + Text(formatTime(audioPlayer.currentTime)) + Spacer() + Text("-" + formatTime(audioPlayer.duration - audioPlayer.currentTime)) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.white.opacity(0.5)) } - .font(.caption.monospacedDigit()) - .foregroundStyle(.white.opacity(0.5)) + .padding(.horizontal, 28) + .padding(.top, 16) + } else { + // Generating state: compact progress indicator with label + VStack(spacing: 8) { + ProgressView() + .tint(.white.opacity(0.7)) + .scaleEffect(1.1) + Text("Generating audio…") + .font(.caption) + .foregroundStyle(.white.opacity(0.5)) + } + .frame(maxWidth: .infinity) + .padding(.top, 20) + .padding(.bottom, 4) } - .padding(.horizontal, 28) - .padding(.top, 24) // Controls HStack(spacing: 0) { // ← skip back 15s Button { audioPlayer.skip(by: -15) } label: { Image(systemName: "gobackward.15") - .font(.system(size: 24, weight: .regular)) - .foregroundStyle(.white.opacity(0.9)) + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(.white.opacity(audioPlayer.status == .generating ? 0.3 : 0.9)) .frame(maxWidth: .infinity) } .buttonStyle(.plain) + .disabled(audioPlayer.status == .generating) // ← previous chapter Button { @@ -466,26 +387,29 @@ struct FullPlayerView: View { } } label: { Image(systemName: "backward.end.fill") - .font(.system(size: 32, weight: .regular)) + .font(.system(size: 28, weight: .regular)) .foregroundStyle(.white.opacity(0.9)) .frame(maxWidth: .infinity) } .buttonStyle(.plain) .disabled(audioPlayer.absolutePrevChapter == nil) - .opacity(audioPlayer.absolutePrevChapter == nil ? 0.5 : 1.0) + .opacity(audioPlayer.absolutePrevChapter == nil ? 0.4 : 1.0) - // play / pause + // play / pause — large circle button Button { audioPlayer.togglePlayPause() } label: { ZStack { + Circle() + .fill(.white.opacity(0.15)) + .frame(width: 64, height: 64) if audioPlayer.status == .generating { ProgressView() .tint(.white) - .scaleEffect(1.3) + .scaleEffect(1.2) } else { Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 38, weight: .bold)) + .font(.system(size: 30, weight: .bold)) .foregroundStyle(.white) - .offset(x: audioPlayer.isPlaying ? 0 : 3) + .offset(x: audioPlayer.isPlaying ? 0 : 2) } } .frame(maxWidth: .infinity) @@ -506,19 +430,18 @@ struct FullPlayerView: View { } label: { ZStack { Image(systemName: "forward.end.fill") - .font(.system(size: 32, weight: .regular)) + .font(.system(size: 28, weight: .regular)) .foregroundStyle(.white.opacity(0.9)) - // Show small loading indicator if next chapter is being prefetched if audioPlayer.nextPrefetchStatus == .prefetching { VStack { Spacer() HStack { Spacer() ProgressView() - .scaleEffect(0.6) + .scaleEffect(0.55) .tint(.amber) - .padding(4) + .padding(3) .background(Circle().fill(.black.opacity(0.6))) } } @@ -528,22 +451,22 @@ struct FullPlayerView: View { } .buttonStyle(.plain) .disabled(audioPlayer.absoluteNextChapter == nil) - .opacity(audioPlayer.absoluteNextChapter == nil ? 0.5 : 1.0) + .opacity(audioPlayer.absoluteNextChapter == nil ? 0.4 : 1.0) // → skip forward 15s Button { audioPlayer.skip(by: 15) } label: { Image(systemName: "goforward.15") - .font(.system(size: 24, weight: .regular)) - .foregroundStyle(.white.opacity(0.9)) + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(.white.opacity(audioPlayer.status == .generating ? 0.3 : 0.9)) .frame(maxWidth: .infinity) } .buttonStyle(.plain) + .disabled(audioPlayer.status == .generating) } .padding(.horizontal, 20) - .padding(.top, 32) + .padding(.top, 20) + .padding(.bottom, 20) - Spacer(minLength: 16) - // Bottom toolbar HStack(spacing: 0) { // AirPlay @@ -551,7 +474,7 @@ struct FullPlayerView: View { .frame(width: 22, height: 22) .frame(maxWidth: .infinity) - // Settings (Speed control) + // Speed control Menu { ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in Button { @@ -565,17 +488,16 @@ struct FullPlayerView: View { } } } label: { - Image(systemName: "gearshape.fill") - .font(.system(size: 22)) + // Show current speed as a badge instead of gear icon + Text("\(audioPlayer.speed, specifier: "%.2g")×") + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.white.opacity(0.7)) .frame(maxWidth: .infinity) } .buttonStyle(.plain) // Collapse - Button { - onDismiss() - } label: { + Button { onDismiss() } label: { Image(systemName: "chevron.down") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(.white.opacity(0.7)) @@ -583,20 +505,8 @@ struct FullPlayerView: View { } .buttonStyle(.plain) - // Comments (placeholder) - Button {} label: { - Image(systemName: "text.bubble") - .font(.system(size: 22)) - .foregroundStyle(.white.opacity(0.7)) - .frame(maxWidth: .infinity) - } - .buttonStyle(.plain) - .disabled(true) - // Queue (show chapters list) - Button { - showingChaptersList = true - } label: { + Button { showingChaptersList = true } label: { Image(systemName: "list.bullet") .font(.system(size: 22)) .foregroundStyle(.white.opacity(0.7)) @@ -605,9 +515,7 @@ struct FullPlayerView: View { .buttonStyle(.plain) // Sleep timer - Button { - showingSleepTimer = true - } label: { + Button { showingSleepTimer = true } label: { Image(systemName: sleepTimerIcon) .font(.system(size: 22)) .foregroundStyle(audioPlayer.sleepTimer != nil ? .amber : .white.opacity(0.7)) @@ -616,7 +524,7 @@ struct FullPlayerView: View { .buttonStyle(.plain) } .padding(.horizontal, 16) - .padding(.bottom, 8) + .padding(.bottom, 12) } .ignoresSafeArea(edges: .bottom) } @@ -678,12 +586,16 @@ struct FullPlayerView: View { } } + private static let yearFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy" + return f + }() + private var yearText: String { // TODO: Could fetch actual publication year from book metadata // For now, return current year or placeholder - let formatter = DateFormatter() - formatter.dateFormat = "yyyy" - return formatter.string(from: Date()) + return Self.yearFormatter.string(from: Date()) } private var sleepTimerIcon: String { diff --git a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift index 56efe3c..dd787f6 100644 --- a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift +++ b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift @@ -68,9 +68,7 @@ struct ProfileView: View { .sheet(isPresented: $showChangePassword) { ChangePasswordView() } - .alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) { - Button("OK") { vm.error = nil } - } message: { Text(vm.error ?? "") } + .errorAlert($vm.error) } } @@ -121,7 +119,7 @@ struct ProfileView: View { } } ), - in: 0.5...3.0, step: 0.25 + in: 0.5...2.0, step: 0.25 ) .tint(.amber) } @@ -208,7 +206,8 @@ struct ChangePasswordView: View { body: Body(currentPassword: current, newPassword: newPwd) ) success = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { dismiss() } + try? await Task.sleep(nanoseconds: 1_200_000_000) + dismiss() } catch { self.error = error.localizedDescription } diff --git a/ios/LibNovel/project.yml b/ios/LibNovel/project.yml index 93af1ba..89e991c 100644 --- a/ios/LibNovel/project.yml +++ b/ios/LibNovel/project.yml @@ -23,10 +23,6 @@ settings: SWIFT_ACTIVE_COMPILATION_CONDITIONS: "" packages: - # Markdown rendering — renders chapter HTML from the server - MarkdownUI: - url: https://github.com/gonzalezreal/swift-markdown-ui - from: "2.4.0" # Async image loading with caching Kingfisher: url: https://github.com/onevcat/Kingfisher @@ -46,7 +42,6 @@ targets: - path: LibNovel/Resources/Assets.xcassets dependencies: - package: Kingfisher - - package: MarkdownUI settings: base: PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel