Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// ProfileView.swift — profil : KA ID (compte unique de l'écosystème), univers3// favoris, apparence, contacts complets du Groupe KA, pages légales, à propos.4import SwiftUI56struct ProfileView: View {7 @AppStorage("ka.favUniverses") private var favUniversesRaw = ""8 @AppStorage("ka.appearance") private var appearance = "clair"9 @Environment(\.colorScheme) private var scheme10 @StateObject private var kaid = KAIDManager.shared11 @EnvironmentObject private var favorites: FavoritesStore12 @EnvironmentObject private var recents: RecentsStore13 @StateObject private var perso = PersonalizationStore.shared14 @AppStorage(PersonalizationStore.enabledKey) private var persoEnabled = true15 @State private var showHubSite = false16 @State private var confirmReset = false1718 private var favIDs: Set<String> {19 Set(favUniversesRaw.split(separator: ",").map(String.init))20 }2122 var body: some View {23 NavigationStack {24 List {25 Section {26 if let p = kaid.profile {27 KAIDCard(profile: p)28 .listRowInsets(EdgeInsets())29 .listRowBackground(Color.clear)30 Link(destination: Ecosystem.hubURL.appendingPathComponent("/compte")) {31 Label("Gérer mon profil sur groupe-ka.com", systemImage: "person.text.rectangle")32 }33 Button(role: .destructive) { kaid.logout() } label: {34 Label("Se déconnecter", systemImage: "rectangle.portrait.and.arrow.right")35 }36 } else {37 HStack(spacing: 14) {38 ZStack {39 RoundedRectangle(cornerRadius: 14, style: .continuous)40 .fill(KATheme.inkLight)41 .frame(width: 56, height: 56)42 Text("KA").font(.system(size: 22, weight: .bold, design: .rounded))43 .foregroundStyle(KATheme.lime)44 .rotationEffect(.degrees(-4))45 }46 VStack(alignment: .leading, spacing: 3) {47 Text("Votre KA ID").font(.headline)48 Text("Un seul compte pour les 13 plateformes de l'écosystème.")49 .font(.caption).foregroundStyle(.secondary)50 }51 }52 Button {53 kaid.login()54 } label: {55 HStack {56 Label("Se connecter avec KA ID", systemImage: "person.crop.circle.badge.checkmark")57 Spacer()58 if kaid.busy { ProgressView() }59 }60 }61 .disabled(kaid.busy)62 Link(destination: Ecosystem.signupURL) {63 Label("Créer un compte sur groupe-ka.com", systemImage: "arrow.up.right.square")64 }65 if let e = kaid.lastError {66 Text(e).font(.caption).foregroundStyle(.red)67 }68 }69 }7071 Section("Mes contenus") {72 NavigationLink { FavoritesView() } label: {73 Label("Favoris & collections", systemImage: "heart.fill")74 .badge(favorites.allItems.count)75 }76 NavigationLink { HistoryView() } label: {77 Label("Historique consulté", systemImage: "clock.arrow.circlepath")78 .badge(recents.viewed.count)79 }80 NavigationLink { SavedSearchesView() } label: {81 Label("Recherches sauvegardées & alertes", systemImage: "bell.badge")82 .badge(recents.alertCount > 0 ? "\(recents.alertCount) nouv." : "")83 }84 }8586 Section {87 Toggle(isOn: $persoEnabled) {88 Label("Recommandations personnalisées", systemImage: "sparkles")89 }90 if persoEnabled, perso.profile.hasSignals {91 let p = perso.profile92 if !p.affinity.isEmpty {93 LabeledContent("Univers suivis",94 value: p.topUniverses(3)95 .compactMap { Ecosystem.universe($0)?.name }96 .joined(separator: ", "))97 }98 if !p.topCities.isEmpty {99 LabeledContent("Villes", value: p.topCities.prefix(3).joined(separator: ", "))100 }101 LabeledContent("Signaux enregistrés", value: "\(p.eventCount)")102 }103 Button(role: .destructive) { confirmReset = true } label: {104 Label("Effacer ce que KA a appris", systemImage: "trash")105 }106 .disabled(perso.events.isEmpty)107 } header: {108 Text("Personnalisation")109 } footer: {110 Text("Vos consultations, favoris et recherches personnalisent l'accueil et le feed « Pour vous ». Tout reste sur cet appareil — rien n'est envoyé aux serveurs.")111 }112113 Section {114 ForEach(Ecosystem.all) { u in115 Button {116 var ids = favIDs117 if ids.contains(u.id) { ids.remove(u.id) } else { ids.insert(u.id) }118 favUniversesRaw = ids.sorted().joined(separator: ",")119 Haptics.tap()120 } label: {121 HStack {122 KALogo(universe: u, size: 26)123 Text(u.name).foregroundStyle(.primary)124 Spacer()125 if favIDs.contains(u.id) {126 Image(systemName: "star.fill").foregroundStyle(.yellow)127 }128 }129 }130 }131 } header: {132 Text("Mes univers favoris")133 } footer: {134 Text("Vos univers favoris remontent en tête de l'accueil.")135 }136137 Section("Apparence") {138 Picker("Thème", selection: $appearance) {139 Text("Automatique").tag("auto")140 Text("Clair").tag("clair")141 Text("Sombre").tag("sombre")142 }143 }144145 Section("Joindre le Groupe KA") {146 ForEach(Ecosystem.contacts, id: \.email) { c in147 Link(destination: URL(string: "mailto:\(c.email)")!) {148 VStack(alignment: .leading, spacing: 2) {149 Text(c.email).font(.subheadline.weight(.semibold))150 Text(c.role).font(.caption).foregroundStyle(.secondary)151 }152 }153 }154 Button { showHubSite = true } label: {155 HStack(spacing: 10) {156 KALogo(assetID: "groupe-ka", size: 22)157 Text("groupe-ka.com — le portail")158 }159 }160 Link(destination: Ecosystem.statusURL) {161 Label("État des services en direct", systemImage: "waveform.path.ecg")162 }163 }164165 Section("Légal") {166 ForEach(Ecosystem.legal, id: \.path) { l in167 Link(destination: Ecosystem.hubURL.appendingPathComponent(l.path)) {168 Text(l.label)169 }170 }171 Text(Ecosystem.disclaimer)172 .font(.caption2).foregroundStyle(.secondary)173 }174175 Section("À propos") {176 LabeledContent("Version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0")177 LabeledContent("Écosystème", value: "\(Ecosystem.all.count) univers")178 Text("KA — tout l'écosystème Groupe-KA dans votre poche. Fait au Québec. 💚")179 .font(.caption).foregroundStyle(.secondary)180 }181 }182 .navigationTitle("Profil")183 .scrollContentBackground(.hidden)184 .background(KATheme.paper(scheme))185 .fullScreenCover(isPresented: $showHubSite) {186 SiteBrowserCover(title: "Groupe KA", accent: KATheme.green,187 logoID: "groupe-ka", url: Ecosystem.hubURL)188 }189 .confirmationDialog("Effacer les données d'apprentissage ?",190 isPresented: $confirmReset, titleVisibility: .visible) {191 Button("Tout effacer", role: .destructive) {192 perso.reset()193 Haptics.success()194 }195 } message: {196 Text("Le feed « Pour vous » repartira de zéro. Vos favoris et votre historique ne sont pas touchés.")197 }198 }199 }200}201202203// MARK: - Historique consulté204205struct HistoryView: View {206 @EnvironmentObject private var recents: RecentsStore207 @Environment(\.colorScheme) private var scheme208 var body: some View {209 ScrollView {210 LazyVStack(spacing: 10) {211 if recents.viewed.isEmpty {212 KAEmptyState(symbol: "clock", title: "Rien de consulté encore",213 message: "Les fiches que vous ouvrez apparaîtront ici.")214 }215 ForEach(recents.viewed) { item in216 NavigationLink(value: item) { KAItemRow(item: item) }217 .buttonStyle(KAPressStyle())218 }219 }220 .padding(14)221 }222 .background(KATheme.paper(scheme))223 .navigationTitle("Historique")224 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }225 .toolbar {226 if !recents.viewed.isEmpty {227 ToolbarItem(placement: .topBarTrailing) {228 Button("Effacer") { recents.clearHistory() }229 }230 }231 }232 }233}234235// MARK: - Recherches sauvegardées & alertes236237struct SavedSearchesView: View {238 @EnvironmentObject private var recents: RecentsStore239 @Environment(\.colorScheme) private var scheme240 var body: some View {241 List {242 if recents.savedSearches.isEmpty {243 KAEmptyState(symbol: "bell.slash", title: "Aucune recherche sauvegardée",244 message: "Dans Recherche, touchez 🔔 pour suivre une recherche : KA recomptera les résultats et affichera les nouveautés.")245 .listRowBackground(Color.clear)246 }247 ForEach($recents.savedSearches) { $s in248 VStack(alignment: .leading, spacing: 6) {249 HStack {250 Text(s.name).font(.headline)251 if s.newCount > 0 {252 Text("+\(s.newCount) nouveautés")253 .font(.system(size: 10, design: .monospaced).weight(.bold))254 .padding(.horizontal, 7).padding(.vertical, 3)255 .background(.red, in: Capsule()).foregroundStyle(.white)256 }257 Spacer()258 Toggle("", isOn: $s.alertsOn).labelsHidden()259 .accessibilityLabel("Alerte pour \(s.name)")260 }261 HStack(spacing: 8) {262 if let u = Ecosystem.universe(s.universeID) { KAChip(text: u.wordmark, accent: u.accent) }263 Text("« \(s.query) »").font(.caption).foregroundStyle(.secondary)264 Spacer()265 if let total = s.lastTotal {266 Text("\(total + s.newCount) résultats")267 .font(.system(.caption2, design: .monospaced))268 .foregroundStyle(.tertiary)269 }270 }271 if s.newCount > 0 {272 Button("Marquer comme vues") { recents.markSeen(s.id) }273 .font(.caption.weight(.semibold))274 }275 }276 .swipeActions {277 Button(role: .destructive) { recents.remove(s.id) } label: {278 Label("Supprimer", systemImage: "trash")279 }280 }281 }282 }283 .navigationTitle("Recherches & alertes")284 .refreshable { await recents.refreshAlerts() }285 .scrollContentBackground(.hidden)286 .background(KATheme.paper(scheme))287 }288}289