import SwiftUI struct ReadingUnavailableView: View { private let label: Label private let description: Description private let actions: Actions init( @ViewBuilder label: () -> Label, @ViewBuilder description: () -> Description, @ViewBuilder actions: () -> Actions ) { self.label = label() self.description = description() self.actions = actions() } var body: some View { if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI { ContentUnavailableView { label } description: { description } actions: { actions } } else { VStack(spacing: 12) { label.labelStyle(ReadingUnavailableLabelStyle()) description .font(.subheadline) .foregroundStyle(.secondary) actions.padding(.top, 4) } .multilineTextAlignment(.center) .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) } } } extension ReadingUnavailableView where Label == SwiftUI.Label, Description == Text?, Actions == EmptyView { init(_ title: String, systemImage: String, description: Text? = nil) { self.init { SwiftUI.Label(title, systemImage: systemImage) } description: { description } actions: { EmptyView() } } @ViewBuilder static func search(text: String) -> some View { if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI { ContentUnavailableView.search(text: text) } else { ReadingUnavailableView( "无搜索结果", systemImage: "magnifyingglass", description: Text("没有找到与“\(text)”相关的内容,请尝试其他关键词。") ) } } } private struct ReadingUnavailableLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { VStack(spacing: 12) { configuration.icon .font(.system(size: 48)) .foregroundStyle(.secondary) .accessibilityHidden(true) configuration.title.font(.title2.bold()) } } } extension View { func readingSearchable(text: Binding, isPresented: Binding, prompt: String) -> some View { modifier(ReadingSearchModifier(text: text, isPresented: isPresented, prompt: prompt)) } @ViewBuilder func readingListTopMargin(_ margin: CGFloat) -> some View { if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI { contentMargins(.top, margin, for: .scrollContent) } else { self } } } private struct ReadingSearchModifier: ViewModifier { @Binding var text: String @Binding var isPresented: Bool let prompt: String @FocusState private var isFocused: Bool func body(content: Content) -> some View { if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI { content.searchable(text: $text, isPresented: $isPresented, prompt: prompt) } else { content .safeAreaInset(edge: .top, spacing: 0) { HStack(spacing: 8) { HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) .accessibilityHidden(true) TextField(prompt, text: $text) .focused($isFocused) .textInputAutocapitalization(.never) .disableAutocorrection(true) .submitLabel(.search) if !text.isEmpty { Button { text = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .accessibilityLabel("清除搜索") } } .padding(10) .background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 10)) if isFocused { Button("取消") { text = "" isFocused = false } } } .padding(.horizontal, 16) .padding(.vertical, 8) .background(ReadingTheme.canvas) } .onAppear { isFocused = isPresented } .onChange(of: isPresented) { isFocused = $0 } .onChange(of: isFocused) { isPresented = $0 } } } } enum ReadingTheme { static let accent = adaptive(0x171717, dark: 0xF2F2F2) static let accentSoft = adaptive(0xEEEEEF, dark: 0x29292C) static let coral = Color(red: 0.76, green: 0.48, blue: 0.27) static let paper = Color(red: 0.965, green: 0.945, blue: 0.895) static let ink = Color.primary static let canvas = adaptive(0xFCFCFC, dark: 0x111113) static let groupedCanvas = adaptive(0xF3F3F4, dark: 0x1C1C1F) static let surface = adaptive(0xFFFFFF, dark: 0x232326) static let separator = adaptive(0xE6E6E8, dark: 0x363639) static let onAccent = adaptive(0xFFFFFF, dark: 0x171717) private static func adaptive(_ light: UInt32, dark: UInt32) -> Color { Color(uiColor: UIColor { traits in let hex = traits.userInterfaceStyle == .dark ? dark : light return UIColor( red: CGFloat((hex >> 16) & 0xff) / 255, green: CGFloat((hex >> 8) & 0xff) / 255, blue: CGFloat(hex & 0xff) / 255, alpha: 1 ) }) } } struct ReadingPageHeader: View { let title: String let subtitle: String @ViewBuilder var actions: Actions var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .center, spacing: 16) { Text(title) .font(.largeTitle.bold()) .foregroundStyle(ReadingTheme.ink) .accessibilityAddTraits(.isHeader) Spacer(minLength: 0) HStack(spacing: 10) { actions } } Text(subtitle) .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 24) .padding(.top, 20) .padding(.bottom, 28) } } struct ReadingToolbarIcon: View { let systemName: String var body: some View { Image(systemName: systemName) .font(.system(size: 19, weight: .regular)) .foregroundStyle(ReadingTheme.accent) .frame(width: 44, height: 44) .background(ReadingTheme.groupedCanvas, in: Circle()) .accessibilityHidden(true) } } enum BookReadingPresentation { static func hasStarted(_ book: Book) -> Bool { book.durChapterIndex > 0 || book.durChapterPos > 0 || book.durChapterTime > 0 } static func progress(_ book: Book) -> Double { guard hasStarted(book), book.totalChapterNum > 0 else { return 0 } return min(1, max(0, Double(book.durChapterIndex + 1) / Double(book.totalChapterNum))) } static func position(_ book: Book) -> String { if let title = book.durChapterTitle, !title.isEmpty { return title } guard hasStarted(book) else { return "尚未阅读" } return book.totalChapterNum > 0 ? "第\(book.durChapterIndex + 1)章 · 共\(book.totalChapterNum)章" : "已读至第\(book.durChapterIndex + 1)章" } static func progressLabel(_ book: Book) -> String { guard hasStarted(book) else { return "尚未阅读" } guard book.totalChapterNum > 0 else { return position(book) } return "已读\(Int((progress(book) * 100).rounded()))%" } } struct ReadingProgressLine: View { let value: Double var body: some View { GeometryReader { geometry in Capsule().fill(ReadingTheme.separator) .overlay(alignment: .leading) { Capsule().fill(ReadingTheme.accent) .frame(width: geometry.size.width * min(1, max(0, value))) } } .frame(height: 3) .accessibilityHidden(true) } } struct BookCoverArtwork: View { let title: String var author: String = "" var remoteURL: String? var width: CGFloat = 68 var height: CGFloat = 96 private var coverStyle: Int { switch title { case "长夜里的灯": 0 case "群星沉默时": 1 case "山海入梦来": 2 default: title.unicodeScalars.reduce(0) { ($0 + Int($1.value)) % 3 } } } private var coverColor: Color { switch coverStyle { case 1: Color(red: 0.22, green: 0.35, blue: 0.44) case 2: Color(red: 0.71, green: 0.44, blue: 0.34) default: Color(red: 0.14, green: 0.15, blue: 0.16) } } var body: some View { AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in if case .success(let image) = phase { image.resizable().scaledToFill() } else { placeholder } } .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) .overlay(alignment: .leading) { Rectangle().fill(.black.opacity(0.15)).frame(width: max(2, width * 0.035)) } .overlay { RoundedRectangle(cornerRadius: 4, style: .continuous) .strokeBorder(.black.opacity(0.08), lineWidth: 0.5) } .accessibilityHidden(true) } private var placeholder: some View { ZStack(alignment: .topLeading) { coverColor Canvas { context, size in drawArtwork(context: context, size: size) } VStack(alignment: .leading, spacing: width * 0.10) { Text(coverStyle == 0 ? title.map(String.init).joined(separator: "\n") : title) .font(.system(size: max(9, width * (coverStyle == 0 ? 0.16 : 0.18)), weight: .medium, design: .serif)) .lineLimit(coverStyle == 0 ? 7 : 4) .minimumScaleFactor(0.65) .frame(maxWidth: coverStyle == 0 ? width * 0.32 : .infinity, alignment: .leading) if !author.isEmpty, width >= 50 { Text(author) .font(.system(size: max(7, width * 0.09))) .foregroundStyle(.white.opacity(0.7)) .lineLimit(1) } } .foregroundStyle(.white.opacity(0.94)) .padding(width * 0.15) .padding(.top, coverStyle == 1 ? height * 0.33 : 0) } } private func drawArtwork(context: GraphicsContext, size: CGSize) { if coverStyle == 0 { let moon = CGRect(x: size.width * 0.68, y: size.height * 0.18, width: size.width * 0.17, height: size.width * 0.17) context.fill(Path(ellipseIn: moon), with: .color(Color(red: 0.89, green: 0.87, blue: 0.81))) } else if coverStyle == 1 { let normalizedPoints: [CGPoint] = [ .init(x: 0.12, y: 0.19), .init(x: 0.39, y: 0.11), .init(x: 0.62, y: 0.22), .init(x: 0.88, y: 0.13), .init(x: 0.76, y: 0.42), .init(x: 0.33, y: 0.48) ] let points = normalizedPoints.map { point in CGPoint(x: point.x * size.width, y: point.y * size.height) } var constellation = Path() constellation.addLines(points) context.stroke(constellation, with: .color(.white.opacity(0.22)), lineWidth: 0.6) for point in points { context.fill(Path(ellipseIn: CGRect(x: point.x, y: point.y, width: 1.5, height: 1.5)), with: .color(.white.opacity(0.7))) } } else { let arch = CGRect(x: size.width * 0.28, y: size.height * 0.40, width: size.width * 0.58, height: size.height * 0.48) context.fill(Path(roundedRect: arch, cornerRadius: size.width * 0.29), with: .color(.white.opacity(0.25))) } for index in 0..<3 { var horizon = Path() let baseline = size.height * (0.78 + CGFloat(index) * 0.065) horizon.move(to: CGPoint(x: 0, y: baseline)) horizon.addCurve( to: CGPoint(x: size.width, y: baseline + 8), control1: CGPoint(x: size.width * 0.3, y: baseline + 18), control2: CGPoint(x: size.width * 0.6, y: baseline - 20) ) context.stroke(horizon, with: .color(.white.opacity(0.17)), lineWidth: 0.7) } } } struct ReadingCard: View { @ViewBuilder let content: Content var body: some View { content .padding(20) .background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) } } struct StatusPill: View { let text: String var color: Color = ReadingTheme.accent var systemImage: String? var body: some View { HStack(spacing: 4) { if let systemImage { Image(systemName: systemImage) } Text(text) } .font(.caption.weight(.medium)) .foregroundStyle(color) } }