All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
139 lines
4.8 KiB
Swift
139 lines
4.8 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Navigation destination enum
|
|
|
|
enum NavDestination: Hashable {
|
|
case book(String) // slug
|
|
case chapter(String, Int) // slug + chapter number
|
|
case userProfile(String) // username
|
|
case browseCategory(sort: String, genre: String, status: String, title: String)
|
|
}
|
|
|
|
// MARK: - View helpers
|
|
|
|
extension View {
|
|
/// Registers app-wide navigationDestination for NavDestination values.
|
|
/// Apply once per NavigationStack.
|
|
func appNavigationDestination() -> some View {
|
|
modifier(AppNavigationDestinationModifier())
|
|
}
|
|
|
|
/// Standard "Error" alert driven by an optional String binding.
|
|
/// Suppresses network errors silently when offline (banner handles them).
|
|
func errorAlert(_ error: Binding<String?>) -> some View {
|
|
modifier(ErrorAlertModifier(error: error))
|
|
}
|
|
|
|
/// Signal to the root overlay that the mini player should be hidden.
|
|
func hideMiniPlayer() -> some View {
|
|
preference(key: HideMiniPlayerKey.self, value: true)
|
|
}
|
|
|
|
/// Marks a cover image as the zoom source for a book navigation transition (iOS 18+).
|
|
func bookCoverZoomSource(slug: String) -> some View {
|
|
modifier(BookCoverZoomSource(slug: slug))
|
|
}
|
|
}
|
|
|
|
// MARK: - Error alert modifier
|
|
|
|
private struct ErrorAlertModifier: ViewModifier {
|
|
@Binding var error: String?
|
|
@EnvironmentObject var networkMonitor: NetworkMonitor
|
|
|
|
private var shouldShowAlert: Bool {
|
|
guard let msg = error else { return false }
|
|
if !networkMonitor.isConnected {
|
|
let keywords = ["internet", "offline", "network", "connection", "unreachable", "timed out", "no data"]
|
|
if keywords.contains(where: { msg.lowercased().contains($0) }) {
|
|
DispatchQueue.main.async { self.error = nil }
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func body(content: Content) -> some View {
|
|
content.alert("Error", isPresented: Binding(
|
|
get: { shouldShowAlert },
|
|
set: { if !$0 { error = nil } }
|
|
)) {
|
|
Button("OK") { error = nil }
|
|
} message: {
|
|
Text(error ?? "")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation destination modifier
|
|
|
|
private struct AppNavigationDestinationModifier: ViewModifier {
|
|
@Namespace private var zoomNamespace
|
|
|
|
func body(content: Content) -> some View {
|
|
if #available(iOS 18.0, *) {
|
|
content
|
|
.navigationDestination(for: NavDestination.self) { dest in
|
|
switch dest {
|
|
case .book(let slug):
|
|
BookDetailView(slug: slug)
|
|
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
|
|
case .chapter(let slug, let n):
|
|
ChapterReaderView(slug: slug, chapterNumber: n)
|
|
case .userProfile(let username):
|
|
UserProfileView(username: username)
|
|
case .browseCategory(let sort, let genre, let status, let title):
|
|
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
|
}
|
|
}
|
|
.environment(\.bookZoomNamespace, zoomNamespace)
|
|
} else {
|
|
content
|
|
.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)
|
|
case .userProfile(let username): UserProfileView(username: username)
|
|
case .browseCategory(let sort, let genre, let status, let title):
|
|
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Environment key: zoom namespace
|
|
|
|
struct BookZoomNamespaceKey: EnvironmentKey {
|
|
static var defaultValue: Namespace.ID? { nil }
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var bookZoomNamespace: Namespace.ID? {
|
|
get { self[BookZoomNamespaceKey.self] }
|
|
set { self[BookZoomNamespaceKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// MARK: - Preference key: hide mini player
|
|
|
|
struct HideMiniPlayerKey: PreferenceKey {
|
|
static var defaultValue = false
|
|
static func reduce(value: inout Bool, nextValue: () -> Bool) { value = value || nextValue() }
|
|
}
|
|
|
|
// MARK: - Cover zoom source modifier
|
|
|
|
struct BookCoverZoomSource: ViewModifier {
|
|
let slug: String
|
|
@Environment(\.bookZoomNamespace) private var namespace
|
|
|
|
func body(content: Content) -> some View {
|
|
if #available(iOS 18.0, *), let ns = namespace {
|
|
content.matchedTransitionSource(id: slug, in: ns)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|