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%
9.6 KB · 289 lines swift
Raw Blame History
1//2//  BrowserView.swift3//  Prisme4//5//  Author: Simon-Pierre Boucher <contact@spboucher.ai>6//78import SwiftUI910/// Main screen: the web content fills the screen, a floating glass chrome11/// sits at the bottom. The page always renders immediately — nothing in the12/// chrome may ever block it (CLAUDE.md §8).13struct BrowserView: View {14    let model: BrowserModel1516    @State private var showTabSwitcher = false17    @State private var showReader = false18    @State private var showLibrary = false1920    var body: some View {21        ZStack(alignment: .bottom) {22            content23                .ignoresSafeArea(edges: .bottom)24            chrome25        }26        .sheet(isPresented: $showTabSwitcher) {27            TabSwitcherView(model: model)28        }29        .fullScreenCover(isPresented: $showReader) {30            if let tab = model.tabs.activeTab {31                ReaderView(32                    tab: tab,33                    intelligence: model.intelligence,34                    accent: model.activeContainer.color.color,35                    library: model.library,36                    onSaveFavorite: { model.saveFavorite(for: tab) }37                )38            }39        }40        .sheet(isPresented: $showLibrary) {41            LibraryView(42                library: model.library,43                containers: model.containers,44                onOpen: { url, containerID in45                    model.selectContainer(containerID)46                    model.submit(.navigate(url))47                }48            )49        }50    }5152    @ViewBuilder53    private var content: some View {54        if let tab = model.tabs.activeTab, !tab.isBlank {55            BrowserWebView(tab: tab, pool: model.pool)56                .id(tab.id)57                .transition(.opacity)58        } else {59            StartPageView(60                active: model.activeContainer,61                containers: model.containers.all,62                onSelect: { model.selectContainer($0) }63            )64        }65    }6667    // MARK: - Chrome6869    private var chrome: some View {70        VStack(spacing: Spacing.m) {71            if let tab = model.tabs.activeTab,72               let insight = tab.insight,73               !tab.insightDismissed,74               insight.url == tab.page.url {75                InsightStrip(76                    insight: insight,77                    digest: readyDigest(for: tab),78                    accent: model.activeContainer.color.color,79                    onRead: { showReader = true },80                    onDismiss: {81                        withAnimation(.spring(duration: 0.3)) {82                            tab.insightDismissed = true83                        }84                    }85                )86                .transition(.move(edge: .bottom).combined(with: .opacity))87            }8889            if let notice = model.notice {90                Text(notice)91                    .font(.footnote.weight(.medium))92                    .padding(.horizontal, Spacing.l)93                    .padding(.vertical, Spacing.s)94                    .glassEffect(.regular, in: Capsule())95                    .transition(.move(edge: .bottom).combined(with: .opacity))96            }9798            AddressBar(99                page: model.tabs.activeTab?.page,100                accent: model.activeContainer.color.color,101                history: model.history,102                onIntent: { model.submit($0) },103                onRecall: { model.open($0) },104                onOpenReader: { showReader = true }105            )106            .id(model.tabs.activeTabID)107108            toolbar109        }110        .padding(.horizontal, Spacing.l)111        .padding(.bottom, Spacing.s)112        .animation(.spring(duration: 0.3), value: model.notice)113        .animation(.spring(duration: 0.35), value: model.tabs.activeTab?.insight)114    }115116    private func readyDigest(for tab: Tab) -> PageDigest? {117        if case .ready(let digest, _) = model.intelligence.digestState(for: tab) {118            return digest119        }120        return nil121    }122123    private var toolbar: some View {124        HStack(spacing: 0) {125            toolbarButton("chevron.left", disabled: model.tabs.activeTab?.page.canGoBack != true) {126                model.tabs.activeTab?.page.goBack()127            }128            toolbarButton("chevron.right", disabled: model.tabs.activeTab?.page.canGoForward != true) {129                model.tabs.activeTab?.page.goForward()130            }131132            Spacer(minLength: 0)133            containerMenu134            Spacer(minLength: 0)135136            toolbarButton("books.vertical") {137                showLibrary = true138            }139140            toolbarButton("plus") {141                withAnimation(.spring(duration: 0.3)) { model.newTab() }142            }143            tabCountButton144        }145        .padding(.horizontal, Spacing.s)146        .frame(height: 54)147        .glassEffect(.regular, in: Capsule())148    }149150    private func toolbarButton(151        _ symbol: String,152        disabled: Bool = false,153        action: @escaping () -> Void154    ) -> some View {155        Button(action: action) {156            Image(systemName: symbol)157                .font(.title3.weight(.medium))158                .frame(width: 52, height: 44)159                .contentShape(Rectangle())160        }161        .buttonStyle(.plain)162        .foregroundStyle(disabled ? Color.secondary.opacity(0.4) : Color.primary)163        .disabled(disabled)164    }165166    private var tabCountButton: some View {167        Button {168            showTabSwitcher = true169        } label: {170            let count = model.tabs.tabs(in: model.activeContainerID).count171            ZStack {172                RoundedRectangle(cornerRadius: 7)173                    .strokeBorder(Color.primary, lineWidth: 2)174                    .frame(width: 24, height: 24)175                Text(count > 0 ? "\(count)" : "")176                    .font(.caption.bold())177                    .monospacedDigit()178            }179            .frame(width: 52, height: 44)180            .contentShape(Rectangle())181        }182        .buttonStyle(.plain)183    }184185    /// One gesture to change universe (identity container, P0).186    private var containerMenu: some View {187        Menu {188            ForEach(model.containers.all) { container in189                Button {190                    withAnimation(.spring(duration: 0.3)) {191                        model.selectContainer(container.id)192                    }193                } label: {194                    Label(container.name, systemImage: container.symbol)195                }196            }197        } label: {198            Image(systemName: model.activeContainer.symbol)199                .font(.subheadline.weight(.semibold))200                .foregroundStyle(.white)201                .frame(width: 40, height: 40)202                .background(model.activeContainer.color.gradient, in: Circle())203        }204        .buttonStyle(.plain)205        .accessibilityLabel(model.activeContainer.name)206    }207}208209// MARK: - Start page210211/// Shown when the active tab has nothing to render yet: the brand mark and212/// a one-tap switch between identity universes.213struct StartPageView: View {214    let active: IdentityContainer215    let containers: [IdentityContainer]216    let onSelect: (IdentityContainer.ID) -> Void217218    var body: some View {219        ZStack {220            LinearGradient(221                colors: [222                    active.color.color.opacity(0.28),223                    active.color.color.opacity(0.06),224                    Color(.systemBackground),225                ],226                startPoint: .top,227                endPoint: .bottom228            )229            .ignoresSafeArea()230231            VStack(spacing: Spacing.l) {232                Spacer()233234                PrismMark()235                    .frame(width: 170, height: 170)236237                Text("Prisme")238                    .font(.system(size: 46, weight: .bold, design: .rounded))239240                Text("Un univers « \(active.name) » — cookies, sessions et empreinte y sont isolés du reste de vos vies.")241                    .font(.callout)242                    .foregroundStyle(.secondary)243                    .multilineTextAlignment(.center)244                    .padding(.horizontal, Spacing.xl * 2)245246                containerChips247                    .padding(.top, Spacing.m)248249                Spacer()250                Spacer()251            }252        }253    }254255    private var containerChips: some View {256        HStack(spacing: Spacing.s) {257            ForEach(containers) { container in258                let isActive = container.id == active.id259                Button {260                    withAnimation(.spring(duration: 0.3)) { onSelect(container.id) }261                } label: {262                    HStack(spacing: Spacing.xs) {263                        Image(systemName: container.symbol)264                            .font(.caption.weight(.semibold))265                        if isActive {266                            Text(container.name)267                                .font(.caption.weight(.semibold))268                                .lineLimit(1)269                                .fixedSize()270                        }271                    }272                    .foregroundStyle(isActive ? .white : container.color.color)273                    .padding(.horizontal, isActive ? Spacing.l : Spacing.m)274                    .padding(.vertical, Spacing.m)275                    .background {276                        if isActive {277                            Capsule().fill(container.color.gradient)278                        } else {279                            Capsule().fill(container.color.color.opacity(0.14))280                        }281                    }282                }283                .buttonStyle(.plain)284            }285        }286        .animation(.spring(duration: 0.3), value: active.id)287    }288}289