SPB Git

spb/prisme Public MIT

Navigateur iOS intelligent — chaque page comprise localement avant d'être affichée. SwiftUI · WebKit · Foundation Models, 100% on-device.

Swift 96.7% JavaScript 3.3%
13.3 KB · 374 lines swift
Raw Blame History
1//2//  ReaderView.swift3//  Prisme4//5//  Author: Simon-Pierre Boucher <contact@spboucher.ai>6//78import SwiftUI910/// Semantic zoom (CLAUDE.md §5). The pinch does not change text size — it11/// changes the level of detail: full text, condensed sections, outline,12/// one sentence. Spreading past the full text dismisses the reader and13/// returns the raw page. The rendering adapts to the page kind (article →14/// serif reading page; documentation → code kept prominent).15struct ReaderView: View {16    @State private var model: ReaderModel17    let accent: Color18    let library: LibraryStore19    let onSaveFavorite: () -> Void2021    @Environment(\.dismiss) private var dismiss22    @GestureState private var pinch: CGFloat = 123    @State private var scrollTarget: Int?2425    /// Single tint for generated content, everywhere in the app (§7).26    static let generated = Color(red: 0.55, green: 0.36, blue: 0.96)2728    init(29        tab: Tab,30        intelligence: IntelligenceCenter,31        accent: Color,32        library: LibraryStore,33        onSaveFavorite: @escaping () -> Void34    ) {35        _model = State(initialValue: ReaderModel(tab: tab, intelligence: intelligence))36        self.accent = accent37        self.library = library38        self.onSaveFavorite = onSaveFavorite39    }4041    var body: some View {42        VStack(spacing: 0) {43            header44            Divider()45            content46                .scaleEffect(pinchScale)47                .opacity(pinchOpacity)48                .animation(.spring(duration: 0.35), value: model.level)49        }50        .background(Color(.systemBackground))51        .gesture(zoomGesture)52        .task { await model.load() }53    }5455    // MARK: - Header5657    private var header: some View {58        VStack(spacing: Spacing.s) {59            HStack {60                Button {61                    dismiss()62                } label: {63                    Image(systemName: "xmark")64                        .font(.subheadline.weight(.semibold))65                        .frame(width: 34, height: 34)66                        .background(.quaternary.opacity(0.5), in: Circle())67                }68                .buttonStyle(.plain)69                .accessibilityIdentifier("reader.close")7071                Spacer()7273                Text(model.title.isEmpty ? "Lecture" : model.title)74                    .font(.footnote.weight(.semibold))75                    .lineLimit(1)7677                Spacer()7879                // Save the page as native data ("Le favori structuré", P0).80                let saved = library.hasFavorite(for: model.tab.page.url)81                Button {82                    if !saved { onSaveFavorite() }83                } label: {84                    Image(systemName: saved ? "bookmark.fill" : "bookmark")85                        .font(.subheadline.weight(.semibold))86                        .foregroundStyle(saved ? accent : .primary)87                        .frame(width: 34, height: 34)88                        .background(.quaternary.opacity(0.5), in: Circle())89                }90                .buttonStyle(.plain)91                .accessibilityIdentifier("reader.save")92            }9394            levelChips95        }96        .padding(.horizontal, Spacing.l)97        .padding(.vertical, Spacing.s)98    }99100    private var levelChips: some View {101        HStack(spacing: Spacing.s) {102            ForEach(ReaderLevel.allCases, id: \.rawValue) { level in103                let selected = model.level == level104                Button {105                    withAnimation(.spring(duration: 0.35)) { model.level = level }106                } label: {107                    Text(level.label)108                        .font(.caption.weight(selected ? .semibold : .regular))109                        .foregroundStyle(selected ? .white : .primary)110                        .padding(.horizontal, Spacing.m)111                        .padding(.vertical, Spacing.s)112                        .background(113                            selected ? AnyShapeStyle(accent) : AnyShapeStyle(.quaternary.opacity(0.5)),114                            in: Capsule()115                        )116                }117                .buttonStyle(.plain)118                .accessibilityIdentifier("reader.level.\(level.label)")119            }120        }121    }122123    // MARK: - Pinch = level of detail124125    private var zoomGesture: some Gesture {126        MagnifyGesture()127            .updating($pinch) { value, state, _ in128                state = value.magnification129            }130            .onEnded { value in131                if value.magnification < 0.8 {132                    // Pinch in: condense.133                    if let next = ReaderLevel(rawValue: model.level.rawValue + 1) {134                        model.level = next135                    }136                } else if value.magnification > 1.25 {137                    // Spread: more detail; past full text, the raw page.138                    if let previous = ReaderLevel(rawValue: model.level.rawValue - 1) {139                        model.level = previous140                    } else {141                        dismiss()142                    }143                }144            }145    }146147    /// The text visibly contracts or expands while pinching (§5: the user148    /// must see it happen, no screen jumps).149    private var pinchScale: CGFloat {150        min(max(pinch, 0.9), 1.1)151    }152153    private var pinchOpacity: Double {154        let deviation = abs(pinch - 1)155        return max(0.6, 1 - deviation * 0.8)156    }157158    // MARK: - Content159160    @ViewBuilder161    private var content: some View {162        if model.extractionFailed {163            ContentUnavailableView {164                Label("Rien à lire ici", systemImage: "book")165            } description: {166                Text("Cette page ne contient pas assez de texte. La page d'origine reste affichée derrière.")167            }168        } else {169            switch model.level {170            case .full: fullText171            case .condensed: condensedSections172            case .outline: outline173            case .gist: gistView174            }175        }176    }177178    /// Article pages read in serif; everything else keeps the system face.179    private var serif: Bool { model.digest?.kind == .article }180181    private var fullText: some View {182        ScrollViewReader { proxy in183            ScrollView {184                LazyVStack(alignment: .leading, spacing: Spacing.l) {185                    ForEach(Array(model.blocks.enumerated()), id: \.offset) { index, block in186                        BlockView(block: block, serif: serif)187                            .id(index)188                    }189                }190                .padding(Spacing.xl)191                .frame(maxWidth: 700, alignment: .leading)192                .frame(maxWidth: .infinity)193            }194            .onAppear {195                if let target = scrollTarget {196                    proxy.scrollTo(target, anchor: .top)197                    scrollTarget = nil198                }199            }200        }201    }202203    private var condensedSections: some View {204        ScrollView {205            LazyVStack(alignment: .leading, spacing: Spacing.m) {206                ForEach(model.sections) { section in207                    let summary = model.summary(for: section)208                    Button {209                        jump(to: section)210                    } label: {211                        VStack(alignment: .leading, spacing: Spacing.s) {212                            Text(section.title)213                                .font(.headline)214                                .foregroundStyle(.primary)215                                .multilineTextAlignment(.leading)216                            if !summary.text.isEmpty {217                                summaryText(summary)218                            }219                        }220                        .padding(Spacing.l)221                        .frame(maxWidth: .infinity, alignment: .leading)222                        .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: Radius.l))223                    }224                    .buttonStyle(.plain)225                }226            }227            .padding(Spacing.l)228        }229    }230231    private var outline: some View {232        ScrollView {233            VStack(alignment: .leading, spacing: 0) {234                ForEach(model.sections) { section in235                    Button {236                        jump(to: section)237                    } label: {238                        HStack(spacing: Spacing.m) {239                            Rectangle()240                                .fill(accent.opacity(0.6))241                                .frame(width: 3, height: 18)242                            Text(section.title)243                                .font(section.level <= 1 ? .body.weight(.semibold) : .subheadline)244                                .foregroundStyle(.primary)245                                .multilineTextAlignment(.leading)246                            Spacer()247                        }248                        .padding(.leading, CGFloat(max(0, section.level - 1)) * Spacing.l)249                        .padding(.vertical, Spacing.m)250                        .contentShape(Rectangle())251                    }252                    .buttonStyle(.plain)253                }254            }255            .padding(Spacing.xl)256        }257    }258259    private var gistView: some View {260        VStack(spacing: Spacing.l) {261            Spacer()262            if let kind = model.digest?.kind {263                Text(kind.label)264                    .font(.caption.weight(.semibold))265                    .foregroundStyle(Self.generated)266                    .padding(.horizontal, Spacing.m)267                    .padding(.vertical, Spacing.xs)268                    .background(Self.generated.opacity(0.12), in: Capsule())269            }270            Text(model.title)271                .font(.footnote.weight(.semibold))272                .foregroundStyle(.secondary)273                .multilineTextAlignment(.center)274            summaryText(model.gist)275                .font(.title2.weight(.medium))276                .multilineTextAlignment(.center)277            Spacer()278            Spacer()279        }280        .padding(Spacing.xl)281        .frame(maxWidth: .infinity)282        .accessibilityIdentifier("reader.gist")283    }284285    /// Generated text always wears the distinct treatment; deterministic286    /// fallbacks look like ordinary interface text (§7).287    @ViewBuilder288    private func summaryText(_ summary: (text: String, generated: Bool)) -> some View {289        if summary.generated {290            HStack(alignment: .top, spacing: Spacing.s) {291                Image(systemName: "sparkles")292                    .font(.caption)293                    .foregroundStyle(Self.generated)294                    .padding(.top, 3)295                Text(summary.text)296                    .italic()297                    .foregroundStyle(.primary)298            }299        } else {300            Text(summary.text)301                .foregroundStyle(.secondary)302        }303    }304305    private func jump(to section: ReaderSection) {306        scrollTarget = section.blockRange.lowerBound307        withAnimation(.spring(duration: 0.35)) { model.level = .full }308    }309}310311// MARK: - Block rendering (adaptive by kind)312313private struct BlockView: View {314    let block: ContentBlock315    let serif: Bool316317    var body: some View {318        switch block.kind {319        case .heading:320            Text(block.text)321                .font(headingFont)322                .padding(.top, block.level <= 2 ? Spacing.m : Spacing.xs)323        case .paragraph:324            Text(block.text)325                .font(serif ? .system(.body, design: .serif) : .body)326                .lineSpacing(5)327        case .listItem:328            HStack(alignment: .top, spacing: Spacing.s) {329                Text("•").foregroundStyle(.secondary)330                Text(block.text)331                    .font(serif ? .system(.body, design: .serif) : .body)332                    .lineSpacing(4)333            }334        case .quote:335            HStack(alignment: .top, spacing: Spacing.m) {336                RoundedRectangle(cornerRadius: 2)337                    .fill(.quaternary)338                    .frame(width: 3)339                Text(block.text)340                    .font(.system(.body, design: serif ? .serif : .default))341                    .italic()342                    .foregroundStyle(.secondary)343            }344        case .code:345            ScrollView(.horizontal, showsIndicators: false) {346                Text(block.text)347                    .font(.system(.callout, design: .monospaced))348                    .padding(Spacing.m)349            }350            .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: Radius.m))351        case .table:352            Text(block.text)353                .font(.system(.caption, design: .monospaced))354                .padding(Spacing.m)355                .frame(maxWidth: .infinity, alignment: .leading)356                .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: Radius.m))357        case .caption:358            Text(block.text)359                .font(.caption)360                .foregroundStyle(.secondary)361        }362    }363364    private var headingFont: Font {365        let design: Font.Design = serif ? .serif : .default366        switch block.level {367        case 1: return .system(.title, design: design).weight(.bold)368        case 2: return .system(.title2, design: design).weight(.bold)369        case 3: return .system(.title3, design: design).weight(.semibold)370        default: return .system(.headline, design: design)371        }372    }373}374