mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 18:51:22 +08:00
381 lines
14 KiB
Swift
381 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
struct ReadingUnavailableView<Label: View, Description: View, Actions: View>: 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<Text, Image>, 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<String>, isPresented: Binding<Bool>, 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<Actions: View>: 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<Content: View>: 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)
|
|
}
|
|
}
|