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) -> 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 } } }