import SwiftUI import CoreText // MARK: - Chapter Reader View struct ChapterReaderView: View { let slug: String let chapterNumber: Int @State private var currentChapter: Int @State private var vm: ChapterReaderViewModel @State private var readerSettings = ReaderSettingsStore() @EnvironmentObject var audioPlayer: AudioPlayerService @EnvironmentObject var authStore: AuthStore @State private var chromeVisible = true @State private var showSettingsPanel = false @State private var showToCSheet = false @Environment(\.dismiss) private var dismiss init(slug: String, chapterNumber: Int) { self.slug = slug self.chapterNumber = chapterNumber _currentChapter = State(initialValue: chapterNumber) _vm = State(initialValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber)) } var body: some View { ZStack { // Full-bleed background readerSettings.settings.theme.backgroundColor .ignoresSafeArea() if vm.isLoading { ProgressView() .tint(readerSettings.settings.theme.textColor) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let content = vm.content { if readerSettings.settings.scrollMode { ScrollReaderContent( content: content, readerSettings: readerSettings, chromeVisible: $chromeVisible, onNavigateChapter: navigateToChapter ) } else { PaginatedReaderContent( content: content, readerSettings: readerSettings, chromeVisible: $chromeVisible, onNavigateChapter: navigateToChapter ) } } else if let errMsg = vm.error { readerErrorView(errMsg) } // Chrome overlay if chromeVisible { VStack(spacing: 0) { topChrome Spacer() if let content = vm.content { bottomChrome(content: content) } } .transition(.opacity.animation(.easeInOut(duration: 0.22))) .ignoresSafeArea(edges: .top) } } .ignoresSafeArea(edges: .all) .navigationBarHidden(true) .toolbar(.hidden, for: .tabBar) .preferredColorScheme(readerSettings.settings.theme.colorScheme) .hideMiniPlayer() .task(id: currentChapter) { await vm.load() } .sheet(isPresented: $showSettingsPanel) { ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel) .presentationDetents([.height(460)]) .presentationDragIndicator(.visible) .presentationCornerRadius(24) .presentationBackground(.regularMaterial) } .sheet(isPresented: $showToCSheet) { if let content = vm.content { ChaptersListSheet( chapters: content.chapters, currentChapter: currentChapter, onChapterSelect: { selected in showToCSheet = false navigateToChapter(selected) } ) .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) } } .onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in guard let next = note.userInfo?["next"] as? Int, let autoNext = note.userInfo?["autoNext"] as? Bool, autoNext, currentChapter == audioPlayer.chapter else { return } navigateToChapter(next) } .onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in guard let next = note.userInfo?["next"] as? Int, currentChapter == audioPlayer.chapter else { return } navigateToChapter(next) } .onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in guard let prev = note.userInfo?["prev"] as? Int, currentChapter == audioPlayer.chapter else { return } navigateToChapter(prev) } } // MARK: - Top chrome private var topChrome: some View { ZStack(alignment: .bottom) { Rectangle() .fill(.ultraThinMaterial) .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) .ignoresSafeArea(edges: .top) VStack(spacing: 0) { HStack(spacing: 0) { // Back Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() dismiss() } label: { Image(systemName: "chevron.left") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(readerSettings.settings.theme.textColor) .frame(width: 44, height: 44) .contentShape(Rectangle()) } .accessibilityLabel("Back") Spacer() // Chapter title if let content = vm.content { Text(content.chapter.title.strippingTrailingDate()) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85)) .lineLimit(1) .frame(maxWidth: 200) } Spacer() // ToC + Aa HStack(spacing: 0) { Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() showToCSheet = true } label: { Image(systemName: "list.bullet") .font(.system(size: 16, weight: .regular)) .foregroundStyle(readerSettings.settings.theme.textColor) .frame(width: 44, height: 44) .contentShape(Rectangle()) } .accessibilityLabel("Table of Contents") Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { showSettingsPanel.toggle() } } label: { Text("Aa") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(readerSettings.settings.theme.textColor) .frame(width: 44, height: 44) .contentShape(Rectangle()) } .accessibilityLabel("Reader Settings") } } .padding(.horizontal, 4) .frame(height: 44) // Progress bar if let content = vm.content { ChapterProgressBar( currentChapter: content.chapter.number, totalChapters: content.chapters.last?.number ?? content.chapter.number, color: accentColor ) } } } .fixedSize(horizontal: false, vertical: true) } // MARK: - Bottom chrome private func bottomChrome(content: ChapterResponse) -> some View { HStack(alignment: .center, spacing: 12) { // Prev chapter if let prev = content.prev { Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() navigateToChapter(prev) } label: { HStack(spacing: 4) { Image(systemName: "chevron.left") .font(.system(size: 12, weight: .bold)) Text("Ch.\(prev)") .font(.caption.weight(.semibold)) } .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) .frame(minWidth: 64) .padding(.vertical, 10) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("Previous chapter \(prev)") } else { Color.clear.frame(width: 64, height: 40) } Spacer(minLength: 0) // Download DownloadAudioButton( slug: slug, chapter: currentChapter, voice: audioPlayer.voice, theme: readerSettings.settings.theme ) // Listen pill ListenButton( audioPlayer: audioPlayer, vm: vm, authStore: authStore, theme: readerSettings.settings.theme ) Spacer(minLength: 0) // Next chapter if let next = content.next { Button { UIImpactFeedbackGenerator(style: .medium).impactOccurred() navigateToChapter(next) } label: { HStack(spacing: 4) { Text("Ch.\(next)") .font(.caption.weight(.semibold)) Image(systemName: "chevron.right") .font(.system(size: 12, weight: .bold)) } .foregroundStyle(.white) .frame(minWidth: 64) .padding(.vertical, 10) .background(Capsule().fill(accentColor)) .contentShape(Capsule()) } .buttonStyle(.plain) .accessibilityLabel("Next chapter \(next)") } else { Color.clear.frame(width: 64, height: 40) } } .padding(.horizontal, 16) .padding(.vertical, 10) .background( Rectangle() .fill(.ultraThinMaterial) .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) .ignoresSafeArea(edges: .bottom) ) } // MARK: - Helpers private var accentColor: Color { readerSettings.settings.theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber } private func readerErrorView(_ msg: String) -> some View { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle") .font(.largeTitle) .foregroundStyle(.orange) Text(msg) .multilineTextAlignment(.center) .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) .padding(.horizontal) Button("Retry") { Task { await vm.load() } } .buttonStyle(.borderedProminent) .tint(.amber) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private func navigateToChapter(_ chapter: Int) { vm.switchChapter(to: chapter) currentChapter = chapter } } // MARK: - Paginated reader content private struct PaginatedReaderContent: View { let content: ChapterResponse let readerSettings: ReaderSettingsStore @Binding var chromeVisible: Bool let onNavigateChapter: (Int) -> Void @State private var pages: [AttributedString] = [] @State private var currentPage: Int = 0 @State private var geometrySize: CGSize = .zero private let topReserve: CGFloat = 80 private let bottomReserve: CGFloat = 64 var body: some View { GeometryReader { geo in let size = geo.size TabView(selection: $currentPage) { ChapterTitlePage(content: content, readerSettings: readerSettings) .tag(-1) .onTapGesture { toggleChrome() } ForEach(Array(pages.enumerated()), id: \.offset) { idx, page in ReaderPage( text: page, readerSettings: readerSettings, pageNumber: idx + 1, totalPages: pages.count ) .tag(idx) .onTapGesture { toggleChrome() } } ChapterEndPage( content: content, readerSettings: readerSettings, onNavigateChapter: onNavigateChapter ) .tag(pages.count) .onTapGesture { toggleChrome() } } .tabViewStyle(.page(indexDisplayMode: .never)) .onAppear { if geometrySize != size { geometrySize = size repaginate(size: size) } } .onChange(of: size) { _, newSize in geometrySize = newSize repaginate(size: newSize) } .onChange(of: readerSettings.settings) { _, _ in repaginate(size: geometrySize) } .onChange(of: content.chapter.number) { _, _ in currentPage = -1 repaginate(size: geometrySize) } } .ignoresSafeArea() .onAppear { currentPage = -1 } .simultaneousGesture( DragGesture(minimumDistance: 40, coordinateSpace: .global) .onEnded { value in let isHorizontal = abs(value.translation.width) > abs(value.translation.height) * 1.5 guard isHorizontal else { return } if value.translation.width > 0, currentPage == -1, let prev = content.prev { onNavigateChapter(prev) } else if value.translation.width < 0, currentPage == pages.count, let next = content.next { onNavigateChapter(next) } } ) } private func toggleChrome() { withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } } private func repaginate(size: CGSize) { guard size.width > 0, size.height > 0 else { return } let settings = readerSettings.settings let hPad: CGFloat = 28 let textWidth = size.width - hPad * 2 let textHeight = size.height - topReserve - bottomReserve let attributed = HTMLParser.toAttributedString( html: content.html, fontSize: settings.fontSize, lineSpacing: settings.lineSpacing, fontName: settings.font.fontName, textColor: settings.theme.textColor ) pages = TextPaginator.paginate( attributed: attributed, width: textWidth, height: textHeight, fontSize: settings.fontSize ) if currentPage > pages.count - 1 { currentPage = max(0, pages.count - 1) } } } // MARK: - Scroll mode reader content private struct ScrollReaderContent: View { let content: ChapterResponse let readerSettings: ReaderSettingsStore @Binding var chromeVisible: Bool let onNavigateChapter: (Int) -> Void var body: some View { let settings = readerSettings.settings let hPad: CGFloat = 24 let accent: Color = settings.theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { // Chapter header VStack(alignment: .leading, spacing: 10) { Text(content.book.title) .font(.system(size: 11, weight: .medium)) .foregroundStyle(settings.theme.textColor.opacity(0.45)) .textCase(.uppercase) .tracking(1.2) Rectangle() .fill(accent.opacity(0.6)) .frame(width: 36, height: 2) Text(content.chapter.title.strippingTrailingDate()) .font(.system(size: 22, weight: .bold, design: .serif)) .foregroundStyle(settings.theme.textColor) if !content.chapter.dateLabel.isEmpty { Text(content.chapter.dateLabel) .font(.caption) .foregroundStyle(settings.theme.textColor.opacity(0.4)) } } .padding(.horizontal, hPad) .padding(.top, 20) .padding(.bottom, 20) // Body let attributed = HTMLParser.toAttributedString( html: content.html, fontSize: settings.fontSize, lineSpacing: settings.lineSpacing, fontName: settings.font.fontName, textColor: settings.theme.textColor ) Text(attributed) .padding(.horizontal, hPad) // Footer VStack(spacing: 16) { Divider().padding(.horizontal, hPad) if let next = content.next { Button { onNavigateChapter(next) } label: { HStack { Text("Next Chapter") .fontWeight(.semibold) Image(systemName: "arrow.right") } .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 50) .background(Capsule().fill(accent)) } .buttonStyle(.plain) .padding(.horizontal, hPad) } } .padding(.vertical, 24) .padding(.bottom, 80) } } .safeAreaInset(edge: .top) { Color.clear.frame(height: 52) } .background(settings.theme.backgroundColor) .ignoresSafeArea() .onTapGesture { withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } } } } // MARK: - Individual reader page (paginated mode) private struct ReaderPage: View { let text: AttributedString let readerSettings: ReaderSettingsStore let pageNumber: Int let totalPages: Int var body: some View { let settings = readerSettings.settings let hPad: CGFloat = 28 let topPad: CGFloat = 80 let bottomPad: CGFloat = 56 GeometryReader { geo in ZStack(alignment: .bottom) { Text(text) .frame(width: geo.size.width - hPad * 2, alignment: .topLeading) .frame(maxHeight: .infinity, alignment: .top) .padding(.horizontal, hPad) .padding(.top, topPad) .padding(.bottom, bottomPad) .frame(maxWidth: .infinity) Text("\(pageNumber) of \(totalPages)") .font(.system(size: 11, weight: .regular).monospacedDigit()) .foregroundStyle(settings.theme.textColor.opacity(0.3)) .padding(.bottom, bottomPad - 24) .frame(maxWidth: .infinity, alignment: .center) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(settings.theme.backgroundColor) } } } // MARK: - Chapter title page private struct ChapterTitlePage: View { let content: ChapterResponse let readerSettings: ReaderSettingsStore private var totalChapters: Int { content.chapters.last?.number ?? content.chapter.number } private var progressPercent: Int { guard totalChapters > 1 else { return 100 } return Int((Double(content.chapter.number) / Double(totalChapters)) * 100) } private var accentColor: Color { readerSettings.settings.theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber } var body: some View { let settings = readerSettings.settings GeometryReader { geo in VStack(alignment: .leading, spacing: 0) { Spacer() VStack(alignment: .leading, spacing: 14) { Text(content.book.title) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(settings.theme.textColor.opacity(0.45)) .textCase(.uppercase) .tracking(1.4) .lineLimit(2) Rectangle() .fill(accentColor) .frame(width: 36, height: 2) .clipShape(Capsule()) Text(content.chapter.title.strippingTrailingDate()) .font(.system(size: min(32, geo.size.width / 10.5), weight: .bold, design: .serif)) .foregroundStyle(settings.theme.textColor) .fixedSize(horizontal: false, vertical: true) .lineSpacing(4) HStack(spacing: 8) { if !content.chapter.dateLabel.isEmpty { Text(content.chapter.dateLabel) .font(.caption) .foregroundStyle(settings.theme.textColor.opacity(0.4)) } if totalChapters > 1 { if !content.chapter.dateLabel.isEmpty { Circle() .fill(settings.theme.textColor.opacity(0.25)) .frame(width: 3, height: 3) } Text("\(progressPercent)% through") .font(.caption.weight(.medium)) .foregroundStyle(accentColor.opacity(0.85)) } } } .padding(.horizontal, 36) Spacer() Spacer() HStack(spacing: 6) { Image(systemName: "arrow.right") .font(.caption2.weight(.semibold)) Text("Swipe to read") .font(.caption2) } .foregroundStyle(settings.theme.textColor.opacity(0.5)) .frame(maxWidth: .infinity, alignment: .center) .padding(.bottom, 96) .phaseAnimator([false, true]) { v, phase in v.offset(x: phase ? 4 : -2).opacity(phase ? 0.55 : 0.15) } animation: { _ in .easeInOut(duration: 0.9) } } .frame(maxWidth: .infinity) .background(settings.theme.backgroundColor) } } } // MARK: - Chapter end page private struct ChapterEndPage: View { let content: ChapterResponse let readerSettings: ReaderSettingsStore let onNavigateChapter: (Int) -> Void @State private var appeared = false private var accentColor: Color { readerSettings.settings.theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber } var body: some View { let settings = readerSettings.settings VStack(spacing: 32) { Spacer() VStack(spacing: 20) { ZStack { Circle().fill(accentColor.opacity(0.07)).frame(width: 96, height: 96) Circle().fill(accentColor.opacity(0.14)).frame(width: 72, height: 72) Image(systemName: "checkmark") .font(.system(size: 28, weight: .semibold)) .foregroundStyle(accentColor) .symbolEffect(.bounce, value: appeared) } .scaleEffect(appeared ? 1 : 0.7) .opacity(appeared ? 1 : 0) .animation(.spring(response: 0.5, dampingFraction: 0.65).delay(0.05), value: appeared) VStack(spacing: 6) { Text("Chapter \(content.chapter.number)") .font(.caption.weight(.semibold)) .foregroundStyle(accentColor) .textCase(.uppercase) .tracking(1.2) Text("Complete") .font(.title2.bold()) .foregroundStyle(settings.theme.textColor) if content.next == nil { Text("You've reached the latest chapter") .font(.subheadline) .foregroundStyle(settings.theme.textColor.opacity(0.4)) .multilineTextAlignment(.center) .padding(.horizontal) } } .opacity(appeared ? 1 : 0) .offset(y: appeared ? 0 : 10) .animation(.easeOut(duration: 0.35).delay(0.15), value: appeared) } if let next = content.next { Button { onNavigateChapter(next) } label: { HStack(spacing: 8) { Text("Chapter \(next)").fontWeight(.semibold) Image(systemName: "arrow.right").font(.system(size: 14, weight: .semibold)) } .foregroundStyle(.white) .frame(height: 52) .frame(maxWidth: 240) .background(Capsule().fill(accentColor)) } .buttonStyle(.plain) .opacity(appeared ? 1 : 0) .offset(y: appeared ? 0 : 12) .animation(.easeOut(duration: 0.35).delay(0.25), value: appeared) .accessibilityLabel("Go to chapter \(next)") } Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(settings.theme.backgroundColor) .onAppear { appeared = true } .onDisappear { appeared = false } } } // MARK: - Chapter progress bar private struct ChapterProgressBar: View { let currentChapter: Int let totalChapters: Int let color: Color private var progress: Double { guard totalChapters > 1 else { return 1.0 } return Double(currentChapter) / Double(totalChapters) } var body: some View { GeometryReader { geo in ZStack(alignment: .leading) { Rectangle().fill(color.opacity(0.10)) Rectangle() .fill(LinearGradient( colors: [color.opacity(0.7), color], startPoint: .leading, endPoint: .trailing )) .frame(width: geo.size.width * progress) .animation(.spring(response: 0.5, dampingFraction: 0.85), value: progress) } } .frame(height: 3) } } // MARK: - Listen button /// Isolated sub-view to avoid re-rendering ChapterReaderView on every audioPlayer update. private struct ListenButton: View { @ObservedObject var audioPlayer: AudioPlayerService let vm: ChapterReaderViewModel @ObservedObject var authStore: AuthStore let theme: ReaderTheme private var isActive: Bool { audioPlayer.isActive && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter } private var isGenerating: Bool { audioPlayer.status == .generating && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter } private var accentColor: Color { theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber } var body: some View { Button { UIImpactFeedbackGenerator(style: .medium).impactOccurred() vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings) } label: { HStack(spacing: 7) { if isGenerating { ProgressView() .scaleEffect(0.75) .tint(isActive ? .white : accentColor) } else { Image(systemName: isActive ? "waveform" : "headphones") .font(.system(size: 15, weight: .semibold)) .contentTransition(.symbolEffect(.replace.downUp)) .symbolEffect(.variableColor.cumulative, isActive: isActive) } Text(isGenerating ? "Generating…" : (isActive ? "Listening" : "Listen")) .font(.subheadline.weight(.semibold)) } .foregroundStyle(isActive ? .white : accentColor) .padding(.horizontal, 18) .padding(.vertical, 10) .background(Capsule().fill(isActive ? accentColor : accentColor.opacity(0.13))) .contentShape(Capsule()) } .buttonStyle(.plain) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isActive) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isGenerating) .accessibilityLabel(isGenerating ? "Generating audio" : (isActive ? "Pause audio" : "Listen")) } } // MARK: - Download audio button private struct DownloadAudioButton: View { let slug: String let chapter: Int let voice: String let theme: ReaderTheme @EnvironmentObject private var downloadService: AudioDownloadService private var key: String { "\(slug)::\(chapter)::\(voice)" } private var isDownloaded: Bool { downloadService.isDownloaded(slug: slug, chapter: chapter, voice: voice) } private var progress: Double? { downloadService.downloads[key]?.progress } private var accentColor: Color { theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber } private var iconColor: Color { theme.textColor.opacity(0.6) } var body: some View { Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() if isDownloaded { try? downloadService.deleteDownload(slug: slug, chapter: chapter, voice: voice) } else if downloadService.downloads[key] != nil { downloadService.cancelDownload(slug: slug, chapter: chapter, voice: voice) } else { Task { try? await downloadService.download(slug: slug, chapter: chapter, voice: voice) } } } label: { Group { if let frac = progress { // In-progress ring ZStack { Circle().stroke(accentColor.opacity(0.2), lineWidth: 2) .frame(width: 22, height: 22) Circle().trim(from: 0, to: frac) .stroke(accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round)) .frame(width: 22, height: 22) .rotationEffect(.degrees(-90)) .animation(.linear(duration: 0.2), value: frac) } } else { Image(systemName: isDownloaded ? "arrow.down.circle.fill" : "arrow.down.circle") .font(.system(size: 20)) .foregroundStyle(isDownloaded ? accentColor : iconColor) .contentTransition(.symbolEffect(.replace.downUp)) } } .frame(width: 44, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel(isDownloaded ? "Delete downloaded audio" : "Download audio") } } // MARK: - Chapters list sheet (ToC) private struct ChaptersListSheet: View { let chapters: [ChapterBrief] let currentChapter: Int let onChapterSelect: (Int) -> Void @State private var searchText = "" private var filtered: [ChapterBrief] { guard !searchText.isEmpty else { return chapters } return chapters.filter { $0.title.localizedCaseInsensitiveContains(searchText) } } var body: some View { NavigationStack { List(filtered) { ch in Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() onChapterSelect(ch.number) } label: { HStack { VStack(alignment: .leading, spacing: 2) { Text(ch.title) .font(.subheadline) .foregroundStyle(ch.number == currentChapter ? Color.amber : .primary) Text("Chapter \(ch.number)") .font(.caption) .foregroundStyle(.secondary) } Spacer() if ch.number == currentChapter { Image(systemName: "bookmark.fill") .font(.caption) .foregroundStyle(Color.amber) } } .contentShape(Rectangle()) } .buttonStyle(.plain) } .listStyle(.plain) .searchable(text: $searchText, prompt: "Search chapters") .navigationTitle("Chapters") .navigationBarTitleDisplayMode(.inline) } } } // MARK: - Reader settings panel struct ReaderSettingsPanel: View { @ObservedObject var store: ReaderSettingsStore @Binding var isPresented: Bool var body: some View { VStack(spacing: 0) { Capsule() .fill(Color(.systemGray4)) .frame(width: 36, height: 5) .padding(.top, 10) .padding(.bottom, 18) ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 22) { // Font size VStack(alignment: .leading, spacing: 10) { ReaderSectionLabel("Font Size") HStack(spacing: 0) { Button { adjustFontSize(-1) } label: { Text("A").font(.system(size: 13, weight: .regular)) .frame(width: 44, height: 44).contentShape(Rectangle()) } .buttonStyle(.plain).foregroundStyle(.primary) Slider( value: Binding( get: { store.settings.fontSize }, set: { v in var s = store.settings; s.fontSize = v; store.update(s) } ), in: 12...26, step: 1 ) .tint(.amber) .padding(.horizontal, 8) Button { adjustFontSize(1) } label: { Text("A").font(.system(size: 21, weight: .semibold)) .frame(width: 44, height: 44).contentShape(Rectangle()) } .buttonStyle(.plain).foregroundStyle(.primary) } } Divider().padding(.horizontal, 4) // Font family VStack(alignment: .leading, spacing: 10) { ReaderSectionLabel("Font") HStack(spacing: 8) { ForEach(ReaderFont.allCases, id: \.self) { font in ReaderFontChip(font: font, isSelected: store.settings.font == font) { var s = store.settings; s.font = font; store.update(s) UIImpactFeedbackGenerator(style: .light).impactOccurred() } } } } Divider().padding(.horizontal, 4) // Theme VStack(alignment: .leading, spacing: 10) { ReaderSectionLabel("Theme") HStack(spacing: 8) { ForEach(ReaderTheme.allCases, id: \.self) { theme in ReaderThemeChip(theme: theme, isSelected: store.settings.theme == theme) { var s = store.settings; s.theme = theme; store.update(s) UIImpactFeedbackGenerator(style: .light).impactOccurred() } } } } Divider().padding(.horizontal, 4) // Line spacing VStack(alignment: .leading, spacing: 10) { ReaderSectionLabel("Line Spacing") HStack(spacing: 8) { Image(systemName: "text.alignleft") .font(.system(size: 13)).foregroundStyle(.secondary).frame(width: 28) Slider( value: Binding( get: { store.settings.lineSpacing }, set: { v in var s = store.settings; s.lineSpacing = v; store.update(s) } ), in: 1.2...2.4, step: 0.1 ) .tint(.amber) Image(systemName: "text.alignleft") .font(.system(size: 20)).foregroundStyle(.secondary).frame(width: 28) } } Divider().padding(.horizontal, 4) // Scroll vs pages HStack { VStack(alignment: .leading, spacing: 2) { Text(store.settings.scrollMode ? "Scroll" : "Pages") .font(.subheadline.weight(.medium)) Text(store.settings.scrollMode ? "Continuous vertical scroll" : "Swipe horizontally between pages") .font(.caption).foregroundStyle(.secondary) } Spacer() Toggle("", isOn: Binding( get: { store.settings.scrollMode }, set: { v in var s = store.settings; s.scrollMode = v; store.update(s) UIImpactFeedbackGenerator(style: .light).impactOccurred() } )) .tint(.amber) .labelsHidden() } Color.clear.frame(height: 8) } .padding(.horizontal, 20) } } } private func adjustFontSize(_ delta: CGFloat) { var s = store.settings s.fontSize = max(12, min(26, s.fontSize + delta)) store.update(s) UIImpactFeedbackGenerator(style: .light).impactOccurred() } } // MARK: - ReaderSettingsStore final class ReaderSettingsStore: ObservableObject { @Published private(set) var settings: ReaderSettings init() { settings = ReaderSettings.load() } func update(_ new: ReaderSettings) { settings = new new.save() } } // MARK: - Settings sub-components private struct ReaderSectionLabel: View { let title: String init(_ title: String) { self.title = title } var body: some View { Text(title) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) .textCase(.uppercase) .tracking(0.8) } } private struct ReaderFontChip: View { let font: ReaderFont let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { Text(font.rawValue) .font(font.fontName.map { Font.custom($0, size: 15) } ?? .system(size: 15)) .frame(maxWidth: .infinity).frame(height: 46) .background( RoundedRectangle(cornerRadius: 12) .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5) ) ) .foregroundStyle(isSelected ? Color.amber : .primary) .scaleEffect(isSelected ? 1.03 : 1.0) } .buttonStyle(.plain) .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) } } private struct ReaderThemeChip: View { let theme: ReaderTheme let isSelected: Bool let action: () -> Void private var label: String { switch theme { case .white: return "White" case .sepia: return "Sepia" case .night: return "Night" } } var body: some View { Button(action: action) { Text(label) .font(.subheadline.weight(isSelected ? .semibold : .regular)) .frame(maxWidth: .infinity).frame(height: 46) .background(theme.backgroundColor) .foregroundStyle(theme.textColor) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(isSelected ? Color.amber : Color(.systemGray4), lineWidth: isSelected ? 2 : 1) ) .clipShape(RoundedRectangle(cornerRadius: 12)) .scaleEffect(isSelected ? 1.03 : 1.0) } .buttonStyle(.plain) .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) } } // MARK: - HTML → AttributedString parser enum HTMLParser { static func stripLeadingChapterHeader(from html: String) -> String { var result = html for _ in 0..<3 { let pattern = #"^(\s*
]*>)(.*?)(
)"# guard let regex = try? NSRegularExpression( pattern: pattern, options: [.dotMatchesLineSeparators, .caseInsensitive] ) else { break } guard let match = regex.firstMatch( in: result, range: NSRange(result.startIndex..., in: result) ) else { break } let innerRange = match.range(at: 2) guard innerRange.location != NSNotFound, let swiftRange = Range(innerRange, in: result) else { break } let inner = String(result[swiftRange]) let plain = inner .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) guard plain.range(of: #"^\d*\s*[Cc]hapter\s+\d+"#, options: .regularExpression) != nil else { break } guard let fullRange = Range(match.range(at: 0), in: result) else { break } result.removeSubrange(fullRange) } return result } static func toAttributedString( html: String, fontSize: CGFloat, lineSpacing: CGFloat, fontName: String?, textColor: Color ) -> AttributedString { let uiFont: UIFont = fontName.flatMap { UIFont(name: $0, size: fontSize) } ?? UIFont.systemFont(ofSize: fontSize) let uiColor = UIColor(textColor) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = (lineSpacing - 1.0) * fontSize paragraphStyle.paragraphSpacing = fontSize * 0.7 let cleanedHtml = stripLeadingChapterHeader(from: html) let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ .documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue ] let nsAttr: NSMutableAttributedString if let parsed = try? NSMutableAttributedString( data: Data(cleanedHtml.utf8), options: options, documentAttributes: nil ) { nsAttr = parsed } else { let plain = cleanedHtml.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) nsAttr = NSMutableAttributedString(string: plain) } let fullRange = NSRange(location: 0, length: nsAttr.length) nsAttr.addAttribute(.font, value: uiFont, range: fullRange) nsAttr.addAttribute(.foregroundColor, value: uiColor, range: fullRange) nsAttr.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) return (try? AttributedString(nsAttr, including: \.uiKit)) ?? AttributedString(nsAttr.string) } } // MARK: - Text paginator enum TextPaginator { static func paginate( attributed: AttributedString, width: CGFloat, height: CGFloat, fontSize: CGFloat ) -> [AttributedString] { guard width > 0, height > 0 else { return [attributed] } let nsAttr = NSAttributedString(attributed) guard nsAttr.length > 0 else { return [] } let framesetter = CTFramesetterCreateWithAttributedString(nsAttr) let path = CGPath(rect: CGRect(x: 0, y: 0, width: width, height: height), transform: nil) var pages: [AttributedString] = [] var startIndex = 0 let totalLength = nsAttr.length var guard_ = 0 while startIndex < totalLength { guard_ += 1 if guard_ > 2000 { break } let range = CFRange(location: startIndex, length: totalLength - startIndex) let frame = CTFramesetterCreateFrame(framesetter, range, path, nil) let visible = CTFrameGetVisibleStringRange(frame) let pageLength = visible.length > 0 ? visible.length : max(1, totalLength - startIndex) let endIndex = min(startIndex + pageLength, totalLength) let pageAttr = nsAttr.attributedSubstring(from: NSRange(location: startIndex, length: endIndex - startIndex)) if let pageAS = try? AttributedString(pageAttr, including: \.uiKit) { pages.append(pageAS) } if visible.length <= 0 { break } startIndex = endIndex } return pages.isEmpty ? [attributed] : pages } }