// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // SiteBrowserView.swift — les sites du Groupe KA DANS l'app : navigateur // WKWebView intégré aux couleurs de l'univers (progression, retour/avancer, // recharger, partager, ouvrir dans Safari). Utilisé en plein écran depuis // chaque univers et inline pour les univers sans liste native (hub). import SwiftUI import WebKit // MARK: - Modèle observable du WKWebView final class SiteWebModel: NSObject, ObservableObject, WKNavigationDelegate { let webView: WKWebView @Published var progress: Double = 0 @Published var loading = false @Published var canGoBack = false @Published var canGoForward = false @Published var currentURL: URL private var observers: [NSKeyValueObservation] = [] init(start: URL) { currentURL = start let config = WKWebViewConfiguration() config.allowsInlineMediaPlayback = true config.defaultWebpagePreferences.allowsContentJavaScript = true webView = WKWebView(frame: .zero, configuration: config) super.init() webView.navigationDelegate = self webView.allowsBackForwardNavigationGestures = true webView.isOpaque = false observers = [ webView.observe(\.estimatedProgress) { [weak self] w, _ in DispatchQueue.main.async { self?.progress = w.estimatedProgress } }, webView.observe(\.isLoading) { [weak self] w, _ in DispatchQueue.main.async { self?.loading = w.isLoading } }, webView.observe(\.canGoBack) { [weak self] w, _ in DispatchQueue.main.async { self?.canGoBack = w.canGoBack } }, webView.observe(\.canGoForward) { [weak self] w, _ in DispatchQueue.main.async { self?.canGoForward = w.canGoForward } }, webView.observe(\.url) { [weak self] w, _ in DispatchQueue.main.async { if let u = w.url { self?.currentURL = u } } }, ] webView.load(URLRequest(url: start)) } // http(s) reste dans la vue ; tel:/mailto:/itms… → le système func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if let u = navigationAction.request.url, let scheme = u.scheme?.lowercased(), !["http", "https", "about", "blob", "data"].contains(scheme) { UIApplication.shared.open(u) decisionHandler(.cancel) return } decisionHandler(.allow) } // les target="_blank" s'ouvrent dans la même vue func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if let u = navigationAction.request.url { webView.load(URLRequest(url: u)) } return nil } } private struct WebViewContainer: UIViewRepresentable { let webView: WKWebView func makeUIView(context: Context) -> WKWebView { webView } func updateUIView(_ uiView: WKWebView, context: Context) {} } // MARK: - Écran navigateur (embarquable : web + progression + barre de contrôle) struct SiteBrowserScreen: View { let accent: Color @ObservedObject var model: SiteWebModel @Environment(\.colorScheme) private var scheme var body: some View { VStack(spacing: 0) { WebViewContainer(webView: model.webView) .overlay(alignment: .top) { if model.loading { ProgressView(value: max(model.progress, 0.06)) .progressViewStyle(.linear) .tint(accent) } } controls } .background(KATheme.paper(scheme)) } private var controls: some View { HStack(spacing: 26) { Button { Haptics.tap(); model.webView.goBack() } label: { Image(systemName: "chevron.backward") } .disabled(!model.canGoBack) .accessibilityLabel("Page précédente") Button { Haptics.tap(); model.webView.goForward() } label: { Image(systemName: "chevron.forward") } .disabled(!model.canGoForward) .accessibilityLabel("Page suivante") Spacer() Text(model.currentURL.host()?.replacingOccurrences(of: "www.", with: "") ?? "") .font(.system(.caption2, design: .monospaced).weight(.bold)) .foregroundStyle(.secondary) .lineLimit(1) Spacer() ShareLink(item: model.currentURL) { Image(systemName: "square.and.arrow.up") } .accessibilityLabel("Partager la page") Button { Haptics.tap() if model.loading { model.webView.stopLoading() } else { model.webView.reload() } } label: { Image(systemName: model.loading ? "xmark" : "arrow.clockwise") } .accessibilityLabel(model.loading ? "Arrêter" : "Recharger") Link(destination: model.currentURL) { Image(systemName: "safari") } .accessibilityLabel("Ouvrir dans Safari") } .font(.body.weight(.semibold)) .tint(accent) .padding(.horizontal, 18) .padding(.vertical, 10) .background(.bar) } } // MARK: - Site embarqué inline (univers sans liste native, ex. le hub) struct EmbeddedSite: View { let universe: Universe @StateObject private var model: SiteWebModel init(universe: Universe) { self.universe = universe _model = StateObject(wrappedValue: SiteWebModel(start: universe.baseURL)) } var body: some View { SiteBrowserScreen(accent: universe.accent, model: model) } } // MARK: - Plein écran (couverture) : titre + logo + fermer struct SiteBrowserCover: View { let title: String let accent: Color let logoID: String @StateObject private var model: SiteWebModel @Environment(\.dismiss) private var dismiss init(universe: Universe, path: String? = nil) { title = universe.name accent = universe.accent logoID = universe.id let url = path.map { universe.baseURL.appendingPathComponent($0) } ?? universe.baseURL _model = StateObject(wrappedValue: SiteWebModel(start: url)) } init(title: String, accent: Color, logoID: String, url: URL) { self.title = title self.accent = accent self.logoID = logoID _model = StateObject(wrappedValue: SiteWebModel(start: url)) } var body: some View { NavigationStack { SiteBrowserScreen(accent: accent, model: model) .toolbar { ToolbarItem(placement: .topBarLeading) { Button { dismiss() } label: { Image(systemName: "xmark") } .accessibilityLabel("Fermer le site") } ToolbarItem(placement: .principal) { HStack(spacing: 8) { KALogo(assetID: logoID, size: 24) Text(title).font(.headline) } } } .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) } .tint(accent) } }