// // ReaderView.swift // Prisme // // Author: Simon-Pierre Boucher // import SwiftUI /// Semantic zoom (CLAUDE.md §5). The pinch does not change text size — it /// changes the level of detail: full text, condensed sections, outline, /// one sentence. Spreading past the full text dismisses the reader and /// returns the raw page. The rendering adapts to the page kind (article → /// serif reading page; documentation → code kept prominent). struct ReaderView: View { @State private var model: ReaderModel let accent: Color let library: LibraryStore let onSaveFavorite: () -> Void @Environment(\.dismiss) private var dismiss @GestureState private var pinch: CGFloat = 1 @State private var scrollTarget: Int? /// Single tint for generated content, everywhere in the app (§7). static let generated = Color(red: 0.55, green: 0.36, blue: 0.96) init( tab: Tab, intelligence: IntelligenceCenter, accent: Color, library: LibraryStore, onSaveFavorite: @escaping () -> Void ) { _model = State(initialValue: ReaderModel(tab: tab, intelligence: intelligence)) self.accent = accent self.library = library self.onSaveFavorite = onSaveFavorite } var body: some View { VStack(spacing: 0) { header Divider() content .scaleEffect(pinchScale) .opacity(pinchOpacity) .animation(.spring(duration: 0.35), value: model.level) } .background(Color(.systemBackground)) .gesture(zoomGesture) .task { await model.load() } } // MARK: - Header private var header: some View { VStack(spacing: Spacing.s) { HStack { Button { dismiss() } label: { Image(systemName: "xmark") .font(.subheadline.weight(.semibold)) .frame(width: 34, height: 34) .background(.quaternary.opacity(0.5), in: Circle()) } .buttonStyle(.plain) .accessibilityIdentifier("reader.close") Spacer() Text(model.title.isEmpty ? "Lecture" : model.title) .font(.footnote.weight(.semibold)) .lineLimit(1) Spacer() // Save the page as native data ("Le favori structuré", P0). let saved = library.hasFavorite(for: model.tab.page.url) Button { if !saved { onSaveFavorite() } } label: { Image(systemName: saved ? "bookmark.fill" : "bookmark") .font(.subheadline.weight(.semibold)) .foregroundStyle(saved ? accent : .primary) .frame(width: 34, height: 34) .background(.quaternary.opacity(0.5), in: Circle()) } .buttonStyle(.plain) .accessibilityIdentifier("reader.save") } levelChips } .padding(.horizontal, Spacing.l) .padding(.vertical, Spacing.s) } private var levelChips: some View { HStack(spacing: Spacing.s) { ForEach(ReaderLevel.allCases, id: \.rawValue) { level in let selected = model.level == level Button { withAnimation(.spring(duration: 0.35)) { model.level = level } } label: { Text(level.label) .font(.caption.weight(selected ? .semibold : .regular)) .foregroundStyle(selected ? .white : .primary) .padding(.horizontal, Spacing.m) .padding(.vertical, Spacing.s) .background( selected ? AnyShapeStyle(accent) : AnyShapeStyle(.quaternary.opacity(0.5)), in: Capsule() ) } .buttonStyle(.plain) .accessibilityIdentifier("reader.level.\(level.label)") } } } // MARK: - Pinch = level of detail private var zoomGesture: some Gesture { MagnifyGesture() .updating($pinch) { value, state, _ in state = value.magnification } .onEnded { value in if value.magnification < 0.8 { // Pinch in: condense. if let next = ReaderLevel(rawValue: model.level.rawValue + 1) { model.level = next } } else if value.magnification > 1.25 { // Spread: more detail; past full text, the raw page. if let previous = ReaderLevel(rawValue: model.level.rawValue - 1) { model.level = previous } else { dismiss() } } } } /// The text visibly contracts or expands while pinching (§5: the user /// must see it happen, no screen jumps). private var pinchScale: CGFloat { min(max(pinch, 0.9), 1.1) } private var pinchOpacity: Double { let deviation = abs(pinch - 1) return max(0.6, 1 - deviation * 0.8) } // MARK: - Content @ViewBuilder private var content: some View { if model.extractionFailed { ContentUnavailableView { Label("Rien à lire ici", systemImage: "book") } description: { Text("Cette page ne contient pas assez de texte. La page d'origine reste affichée derrière.") } } else { switch model.level { case .full: fullText case .condensed: condensedSections case .outline: outline case .gist: gistView } } } /// Article pages read in serif; everything else keeps the system face. private var serif: Bool { model.digest?.kind == .article } private var fullText: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: Spacing.l) { ForEach(Array(model.blocks.enumerated()), id: \.offset) { index, block in BlockView(block: block, serif: serif) .id(index) } } .padding(Spacing.xl) .frame(maxWidth: 700, alignment: .leading) .frame(maxWidth: .infinity) } .onAppear { if let target = scrollTarget { proxy.scrollTo(target, anchor: .top) scrollTarget = nil } } } } private var condensedSections: some View { ScrollView { LazyVStack(alignment: .leading, spacing: Spacing.m) { ForEach(model.sections) { section in let summary = model.summary(for: section) Button { jump(to: section) } label: { VStack(alignment: .leading, spacing: Spacing.s) { Text(section.title) .font(.headline) .foregroundStyle(.primary) .multilineTextAlignment(.leading) if !summary.text.isEmpty { summaryText(summary) } } .padding(Spacing.l) .frame(maxWidth: .infinity, alignment: .leading) .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: Radius.l)) } .buttonStyle(.plain) } } .padding(Spacing.l) } } private var outline: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { ForEach(model.sections) { section in Button { jump(to: section) } label: { HStack(spacing: Spacing.m) { Rectangle() .fill(accent.opacity(0.6)) .frame(width: 3, height: 18) Text(section.title) .font(section.level <= 1 ? .body.weight(.semibold) : .subheadline) .foregroundStyle(.primary) .multilineTextAlignment(.leading) Spacer() } .padding(.leading, CGFloat(max(0, section.level - 1)) * Spacing.l) .padding(.vertical, Spacing.m) .contentShape(Rectangle()) } .buttonStyle(.plain) } } .padding(Spacing.xl) } } private var gistView: some View { VStack(spacing: Spacing.l) { Spacer() if let kind = model.digest?.kind { Text(kind.label) .font(.caption.weight(.semibold)) .foregroundStyle(Self.generated) .padding(.horizontal, Spacing.m) .padding(.vertical, Spacing.xs) .background(Self.generated.opacity(0.12), in: Capsule()) } Text(model.title) .font(.footnote.weight(.semibold)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) summaryText(model.gist) .font(.title2.weight(.medium)) .multilineTextAlignment(.center) Spacer() Spacer() } .padding(Spacing.xl) .frame(maxWidth: .infinity) .accessibilityIdentifier("reader.gist") } /// Generated text always wears the distinct treatment; deterministic /// fallbacks look like ordinary interface text (§7). @ViewBuilder private func summaryText(_ summary: (text: String, generated: Bool)) -> some View { if summary.generated { HStack(alignment: .top, spacing: Spacing.s) { Image(systemName: "sparkles") .font(.caption) .foregroundStyle(Self.generated) .padding(.top, 3) Text(summary.text) .italic() .foregroundStyle(.primary) } } else { Text(summary.text) .foregroundStyle(.secondary) } } private func jump(to section: ReaderSection) { scrollTarget = section.blockRange.lowerBound withAnimation(.spring(duration: 0.35)) { model.level = .full } } } // MARK: - Block rendering (adaptive by kind) private struct BlockView: View { let block: ContentBlock let serif: Bool var body: some View { switch block.kind { case .heading: Text(block.text) .font(headingFont) .padding(.top, block.level <= 2 ? Spacing.m : Spacing.xs) case .paragraph: Text(block.text) .font(serif ? .system(.body, design: .serif) : .body) .lineSpacing(5) case .listItem: HStack(alignment: .top, spacing: Spacing.s) { Text("•").foregroundStyle(.secondary) Text(block.text) .font(serif ? .system(.body, design: .serif) : .body) .lineSpacing(4) } case .quote: HStack(alignment: .top, spacing: Spacing.m) { RoundedRectangle(cornerRadius: 2) .fill(.quaternary) .frame(width: 3) Text(block.text) .font(.system(.body, design: serif ? .serif : .default)) .italic() .foregroundStyle(.secondary) } case .code: ScrollView(.horizontal, showsIndicators: false) { Text(block.text) .font(.system(.callout, design: .monospaced)) .padding(Spacing.m) } .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: Radius.m)) case .table: Text(block.text) .font(.system(.caption, design: .monospaced)) .padding(Spacing.m) .frame(maxWidth: .infinity, alignment: .leading) .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: Radius.m)) case .caption: Text(block.text) .font(.caption) .foregroundStyle(.secondary) } } private var headingFont: Font { let design: Font.Design = serif ? .serif : .default switch block.level { case 1: return .system(.title, design: design).weight(.bold) case 2: return .system(.title2, design: design).weight(.bold) case 3: return .system(.title3, design: design).weight(.semibold) default: return .system(.headline, design: design) } } }