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%
7.3 KB · 198 lines swift
Raw Blame History
1//2//  TabSwitcherView.swift3//  Prisme4//5//  Author: Simon-Pierre Boucher <contact@spboucher.ai>6//78import SwiftUI910/// Tab overview, sectioned by identity container. Intention-based grouping11/// (P0) will be *proposed* on top of this later — never imposed.12struct TabSwitcherView: View {13    let model: BrowserModel1415    @Environment(\.dismiss) private var dismiss16    @State private var showHistory = false1718    private let columns = [GridItem(.adaptive(minimum: 160), spacing: Spacing.m)]1920    var body: some View {21        NavigationStack {22            Group {23                if model.tabs.tabs.isEmpty {24                    ContentUnavailableView(25                        "Aucun onglet",26                        systemImage: "square.on.square",27                        description: Text("Touchez + pour ouvrir un onglet dans l'univers actif.")28                    )29                } else {30                    grid31                }32            }33            .navigationTitle("Onglets")34            .navigationBarTitleDisplayMode(.inline)35            .toolbar {36                ToolbarItem(placement: .cancellationAction) {37                    Button("OK") { dismiss() }38                        .fontWeight(.semibold)39                }40                ToolbarItem(placement: .primaryAction) {41                    Button {42                        model.newTab()43                        dismiss()44                    } label: {45                        Image(systemName: "plus")46                    }47                }48                ToolbarItem(placement: .secondaryAction) {49                    Button {50                        showHistory = true51                    } label: {52                        Label("Historique", systemImage: "clock.arrow.circlepath")53                    }54                    .accessibilityIdentifier("tabs.history")55                }56            }57            .sheet(isPresented: $showHistory) {58                HistoryView(59                    history: model.history,60                    containers: model.containers,61                    onOpen: { visit in62                        model.open(visit)63                        dismiss()64                    }65                )66            }67        }68    }6970    private var grid: some View {71        ScrollView {72            LazyVGrid(columns: columns, alignment: .leading, spacing: Spacing.m) {73                ForEach(model.containers.all) { container in74                    let tabs = model.tabs.tabs(in: container.id)75                    if !tabs.isEmpty {76                        Section {77                            ForEach(tabs) { tab in78                                TabCard(79                                    tab: tab,80                                    container: container,81                                    isActive: tab.id == model.tabs.activeTabID,82                                    onSelect: {83                                        model.activate(tab)84                                        dismiss()85                                    },86                                    onClose: {87                                        withAnimation(.spring(duration: 0.3)) {88                                            model.tabs.close(tab)89                                        }90                                    }91                                )92                                .transition(.scale(scale: 0.85).combined(with: .opacity))93                            }94                        } header: {95                            HStack(spacing: Spacing.s) {96                                Image(systemName: container.symbol)97                                    .font(.subheadline.weight(.semibold))98                                Text(container.name)99                                    .font(.headline)100                                Text("\(tabs.count)")101                                    .font(.subheadline.weight(.semibold))102                                    .foregroundStyle(.secondary)103                            }104                            .foregroundStyle(container.color.color)105                            .padding(.top, Spacing.l)106                        }107                    }108                }109            }110            .padding(Spacing.l)111        }112    }113}114115// MARK: - Card116117private struct TabCard: View {118    let tab: Tab119    let container: IdentityContainer120    let isActive: Bool121    let onSelect: () -> Void122    let onClose: () -> Void123124    var body: some View {125        Button(action: onSelect) {126            VStack(alignment: .leading, spacing: 0) {127                // Container-colored crown.128                Rectangle()129                    .fill(container.color.gradient)130                    .frame(height: 5)131132                VStack(alignment: .leading, spacing: Spacing.s) {133                    HStack(alignment: .top) {134                        siteBadge135                        Spacer(minLength: Spacing.s)136                        Button(action: onClose) {137                            Image(systemName: "xmark")138                                .font(.caption.bold())139                                .foregroundStyle(.secondary)140                                .frame(width: 26, height: 26)141                                .background(.quaternary, in: Circle())142                                .contentShape(Circle())143                        }144                        .buttonStyle(.plain)145                    }146147                    Text(tab.displayTitle)148                        .font(.subheadline.weight(.semibold))149                        .lineLimit(2)150                        .multilineTextAlignment(.leading)151                        .foregroundStyle(.primary)152153                    if let host = tab.page.url?.host() {154                        Text(host)155                            .font(.caption)156                            .foregroundStyle(.secondary)157                            .lineLimit(1)158                    }159                }160                .padding(Spacing.m)161            }162            .frame(maxWidth: .infinity, minHeight: 118, alignment: .topLeading)163            .background(.regularMaterial, in: RoundedRectangle(cornerRadius: Radius.l))164            .overlay {165                RoundedRectangle(cornerRadius: Radius.l)166                    .strokeBorder(167                        isActive ? AnyShapeStyle(container.color.color) : AnyShapeStyle(.quaternary),168                        lineWidth: isActive ? 2.5 : 1169                    )170            }171            .clipShape(RoundedRectangle(cornerRadius: Radius.l))172            .shadow(color: .black.opacity(isActive ? 0.14 : 0.06), radius: 10, y: 4)173        }174        .buttonStyle(.plain)175    }176177    /// Favicon placeholder: first letter of the host in the container tint.178    private var siteBadge: some View {179        let letter = tab.page.url?.host()?180            .replacingOccurrences(of: "www.", with: "")181            .prefix(1).uppercased()182        return ZStack {183            Circle()184                .fill(container.color.color.opacity(0.16))185                .frame(width: 30, height: 30)186            if let letter, !letter.isEmpty {187                Text(letter)188                    .font(.footnote.bold())189                    .foregroundStyle(container.color.color)190            } else {191                Image(systemName: "globe")192                    .font(.footnote.weight(.semibold))193                    .foregroundStyle(container.color.color)194            }195        }196    }197}198