Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// OnboardingView.swift — le « waouh » de la première ouverture : animation du3// logo KA (boîte encre qui se redresse, lime qui s'allume), pitch en 3 lignes,4// choix des univers favoris, puis c'est parti. Court, spectaculaire, utile.5// Reduce Motion respecté (l'animation se fige proprement).6import SwiftUI78struct OnboardingView: View {9 @AppStorage("ka.onboarded") private var onboarded = false10 @AppStorage("ka.favUniverses") private var favUniversesRaw = ""11 @Environment(\.accessibilityReduceMotion) private var reduceMotion12 @Environment(\.colorScheme) private var scheme1314 @State private var step = 015 @State private var logoRotation: Double = -1416 @State private var logoScale: CGFloat = 0.617 @State private var glow = false18 @State private var picked: Set<String> = []1920 var body: some View {21 VStack(spacing: 0) {22 Spacer()23 logo24 Spacer()25 if step == 0 { pitch } else { pickUniverses }26 actions27 }28 .padding(24)29 .background {30 ZStack {31 KATheme.paper(scheme)32 KAAurora()33 }34 .ignoresSafeArea()35 }36 .onAppear {37 guard !reduceMotion else { logoRotation = -4; logoScale = 1; glow = true; return }38 withAnimation(.spring(response: 0.9, dampingFraction: 0.55).delay(0.15)) {39 logoRotation = -440 logoScale = 141 }42 withAnimation(.easeInOut(duration: 1.1).delay(0.5)) { glow = true }43 }44 }4546 private var logo: some View {47 VStack(spacing: 18) {48 ZStack {49 RoundedRectangle(cornerRadius: 30, style: .continuous)50 .fill(KATheme.inkLight)51 .frame(width: 128, height: 128)52 .shadow(color: KATheme.lime.opacity(glow ? 0.55 : 0), radius: glow ? 36 : 0)53 Text("KA")54 .font(.system(size: 56, weight: .bold, design: .rounded))55 .foregroundStyle(KATheme.lime)56 }57 .rotationEffect(.degrees(logoRotation))58 .scaleEffect(logoScale)59 .accessibilityHidden(true)60 Text("Mille sites. Un seul KA.")61 .font(.system(.title2, design: .rounded).weight(.bold))62 .multilineTextAlignment(.center)63 }64 }6566 private var pitch: some View {67 VStack(alignment: .leading, spacing: 14) {68 pitchRow("magnifyingglass", "Une recherche, tout le Québec", "Logements, propriétés, autos, emplois, restos, sorties — interrogés d'un coup.")69 pitchRow("heart.fill", "Des favoris qui traversent les univers", "Un 4½, une auto et un resto dans une même collection.")70 pitchRow("sparkles", "KA Agent, votre IA d'ici", "Posez une question, il fouille les vraies données de l'écosystème.")71 pitchRow("location.north.fill", "Ka Trajet, le GPS maison", "Itinéraires, conduite 3D et les adresses de l'écosystème sur la carte.")72 }73 .padding(.bottom, 12)74 }7576 private func pitchRow(_ symbol: String, _ title: String, _ sub: String) -> some View {77 HStack(alignment: .top, spacing: 12) {78 Image(systemName: symbol)79 .font(.headline).foregroundStyle(KATheme.green)80 .frame(width: 34, height: 34)81 .background(KATheme.green.opacity(0.12), in: RoundedRectangle(cornerRadius: 9))82 VStack(alignment: .leading, spacing: 2) {83 Text(title).font(.subheadline.weight(.bold))84 Text(sub).font(.caption).foregroundStyle(.secondary)85 }86 }87 }8889 private var pickUniverses: some View {90 VStack(alignment: .leading, spacing: 10) {91 Text("Vos univers du quotidien ?")92 .font(.headline)93 Text("Ils remonteront en tête de votre accueil (modifiable dans Profil).")94 .font(.caption).foregroundStyle(.secondary)95 LazyVGrid(columns: [GridItem(.adaptive(minimum: 105), spacing: 8)], spacing: 8) {96 ForEach(Ecosystem.all) { u in97 let on = picked.contains(u.id)98 Button {99 Haptics.tap()100 if on { picked.remove(u.id) } else { picked.insert(u.id) }101 } label: {102 VStack(spacing: 4) {103 Image(systemName: u.symbol).font(.subheadline)104 Text(u.name).font(.caption2.weight(.bold)).lineLimit(1)105 }106 .frame(maxWidth: .infinity).padding(.vertical, 10)107 .background(on ? u.accent : KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 11, style: .continuous))108 .foregroundStyle(on ? .white : .primary)109 .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(.primary.opacity(0.2), lineWidth: 1))110 }111 }112 }113 }114 .padding(.bottom, 12)115 }116117 private var actions: some View {118 Button {119 Haptics.success()120 if step == 0 {121 withAnimation(.snappy) { step = 1 }122 } else {123 favUniversesRaw = picked.sorted().joined(separator: ",")124 withAnimation(.easeOut) { onboarded = true }125 }126 } label: {127 Text(step == 0 ? "Continuer" : picked.isEmpty ? "Explorer sans choisir" : "C'est parti 🚀")128 .font(.headline)129 .frame(maxWidth: .infinity).padding(.vertical, 15)130 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 14, style: .continuous))131 .foregroundStyle(KATheme.lime)132 }133 .padding(.top, 4)134 }135}136