Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// KAMotion.swift — le langage de mouvement v3 « légendaire » de la super-app :3// dock flottant signature (encre + lime, ombre dure), ticker encre en marquee4// (comme les héros des sites), compteurs à chiffres roulants, fond aurora5// discret, transitions de défilement. Reduce Motion respecté partout.6import SwiftUI78// MARK: - Dock flottant (remplace la tab bar système)910struct KADock: View {11 @Binding var tab: Int12 var alertCount: Int13 var onAgent: () -> Void14 @Namespace private var ns15 @Environment(\.colorScheme) private var scheme1617 static let items: [(icon: String, label: String)] = [18 ("house.fill", "Accueil"),19 ("magnifyingglass", "Recherche"),20 ("circle.grid.3x3.fill", "Explorer"),21 ("map.fill", "Carte"),22 ("location.north.fill", "Trajet"),23 ("person.crop.circle.fill", "Profil"),24 ]2526 var body: some View {27 HStack(spacing: 2) {28 ForEach(Array(Self.items.enumerated()), id: \.offset) { i, item in29 Button {30 guard tab != i else { return }31 Haptics.tap()32 withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { tab = i }33 } label: {34 ZStack(alignment: .topTrailing) {35 Image(systemName: item.icon)36 .font(.system(size: 17, weight: .semibold))37 .foregroundStyle(tab == i ? KATheme.inkLight : KATheme.inkDark.opacity(0.72))38 .frame(width: 43, height: 43)39 .background {40 if tab == i {41 Circle().fill(KATheme.lime)42 .matchedGeometryEffect(id: "ka.dock.active", in: ns)43 }44 }45 if i == Self.items.count - 1 && alertCount > 0 {46 Circle().fill(.red).frame(width: 8, height: 8)47 .offset(x: -5, y: 5)48 .accessibilityHidden(true)49 }50 }51 }52 .accessibilityLabel(item.label + (i == Self.items.count - 1 && alertCount > 0 ? ", \(alertCount) alertes" : ""))53 .accessibilityAddTraits(tab == i ? [.isSelected] : [])54 }55 Rectangle().fill(KATheme.inkDark.opacity(0.25))56 .frame(width: 1, height: 24)57 .padding(.horizontal, 4)58 .accessibilityHidden(true)59 Button {60 Haptics.rigid()61 onAgent()62 } label: {63 Text("Ka")64 .font(KAFont.display(15))65 .foregroundStyle(KATheme.lime)66 .frame(width: 43, height: 43)67 .background(Circle().strokeBorder(KATheme.lime, lineWidth: 1.5))68 }69 .accessibilityLabel("Ouvrir KA Agent, l'assistant de l'écosystème")70 }71 .padding(6)72 .background(KATheme.inkLight, in: Capsule())73 .overlay(Capsule().strokeBorder(.white.opacity(0.10), lineWidth: 1))74 // signature Ka : ombre décalée DURE, lime en clair, feutrée en sombre75 .background(76 Capsule()77 .fill(scheme == .dark ? KATheme.lime.opacity(0.30) : KATheme.lime)78 .offset(x: 5, y: 5)79 )80 .shadow(color: .black.opacity(0.22), radius: 14, y: 8)81 }82}8384// MARK: - Ticker encre (marquee des héros de sites : mono lime qui défile)8586struct KATicker: View {87 let items: [String]88 @State private var textWidth: CGFloat = 089 @Environment(\.accessibilityReduceMotion) private var reduceMotion9091 private var line: String {92 let parts = items.isEmpty ? ["GROUPE KA — LE QUÉBEC EN DIRECT"] : items93 return parts.map { $0.uppercased() }.joined(separator: " ✦ ") + " ✦ "94 }9596 var body: some View {97 Group {98 if reduceMotion {99 Text(line)100 .font(KAFont.mono(11))101 .lineLimit(1)102 .padding(.horizontal, 10)103 .frame(maxWidth: .infinity, alignment: .leading)104 } else {105 // le marquee vit en OVERLAY d'un espace fixe : sa largeur réelle106 // (des milliers de points) ne se propage jamais à la mise en page107 Color.clear108 .frame(height: 15)109 .overlay(alignment: .leading) {110 TimelineView(.animation(minimumInterval: 1.0 / 30)) { tl in111 let t = tl.date.timeIntervalSinceReferenceDate112 let x = textWidth > 0113 ? -CGFloat((t * 42).truncatingRemainder(dividingBy: textWidth))114 : 0115 HStack(spacing: 0) {116 tickerText117 .background(GeometryReader { g in118 Color.clear119 .onAppear { textWidth = g.size.width }120 .onChange(of: g.size.width) { _, w in textWidth = w }121 })122 tickerText123 }124 .fixedSize()125 .offset(x: x)126 }127 }128 .clipped()129 }130 }131 .padding(.vertical, 9)132 .foregroundStyle(KATheme.lime)133 .background(KATheme.inkLight)134 .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))135 .background(136 RoundedRectangle(cornerRadius: 8, style: .continuous)137 .fill(KATheme.green.opacity(0.9))138 .offset(x: 4, y: 4)139 )140 .accessibilityElement(children: .ignore)141 .accessibilityLabel("Le pouls de l'écosystème : " + items.joined(separator: ", "))142 }143144 private var tickerText: some View {145 Text(line)146 .font(KAFont.mono(11))147 .fixedSize()148 .padding(.leading, 2)149 }150}151152// MARK: - Compteur à chiffres roulants (pouls, stats héros)153154struct KACountUp: View {155 let value: Int156 var font: Font = .system(.headline, design: .rounded).weight(.bold)157 var color: Color = .primary158 @State private var shown = 0159 @Environment(\.accessibilityReduceMotion) private var reduceMotion160161 var body: some View {162 Text(shown.formatted(.number.locale(Locale(identifier: "fr_CA"))))163 .font(font)164 .foregroundStyle(color)165 .contentTransition(.numericText(value: Double(shown)))166 .onAppear {167 if reduceMotion { shown = value }168 else { withAnimation(.easeOut(duration: 1.1)) { shown = value } }169 }170 .onChange(of: value) { _, v in171 withAnimation(.easeOut(duration: 0.8)) { shown = v }172 }173 }174}175176// MARK: - Surligneur lime animé (le « titre surligné » des sites)177178struct KAHighlighted: ViewModifier {179 @State private var on = false180 @Environment(\.accessibilityReduceMotion) private var reduceMotion181182 func body(content: Content) -> some View {183 content184 .padding(.horizontal, 7)185 .padding(.vertical, 1)186 .background(187 RoundedRectangle(cornerRadius: 6, style: .continuous)188 .fill(KATheme.lime)189 .rotationEffect(.degrees(-1.4))190 .scaleEffect(x: on ? 1 : 0.02, anchor: .leading)191 )192 .foregroundStyle(KATheme.inkLight)193 .onAppear {194 if reduceMotion { on = true }195 else { withAnimation(.spring(response: 0.7, dampingFraction: 0.8).delay(0.25)) { on = true } }196 }197 }198}199200extension View {201 /// Surligne le texte d'un marqueur lime qui se déploie (signature des sites).202 func kaHighlighted() -> some View { modifier(KAHighlighted()) }203204 /// Pop de défilement : les cartes entrent/sortent avec échelle + fondu.205 func kaScrollPop(axis: Axis = .horizontal) -> some View {206 scrollTransition(.interactive, axis: axis) { content, phase in207 content208 .scaleEffect(phase.isIdentity ? 1 : 0.93)209 .opacity(phase.isIdentity ? 1 : 0.55)210 }211 }212}213214// MARK: - Flux d'étiquettes (retour à la ligne naturel, façon chips des sites)215216struct KAFlow: Layout {217 var spacing: CGFloat = 8218219 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {220 let maxW = proposal.width ?? .infinity221 var x: CGFloat = 0, y: CGFloat = 0, rowH: CGFloat = 0222 for v in subviews {223 let s = v.sizeThatFits(.unspecified)224 if x + s.width > maxW, x > 0 { x = 0; y += rowH + spacing; rowH = 0 }225 x += s.width + spacing226 rowH = max(rowH, s.height)227 }228 return CGSize(width: maxW == .infinity ? max(0, x - spacing) : maxW, height: y + rowH)229 }230231 func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {232 var x = bounds.minX, y = bounds.minY, rowH: CGFloat = 0233 for v in subviews {234 let s = v.sizeThatFits(.unspecified)235 if x + s.width > bounds.maxX, x > bounds.minX { x = bounds.minX; y += rowH + spacing; rowH = 0 }236 v.place(at: CGPoint(x: x, y: y), proposal: .unspecified)237 x += s.width + spacing238 rowH = max(rowH, s.height)239 }240 }241}242243// MARK: - Aurora — deux lueurs lime/vert qui dérivent, très discrètes244245struct KAAurora: View {246 @Environment(\.accessibilityReduceMotion) private var reduceMotion247 @Environment(\.colorScheme) private var scheme248249 var body: some View {250 GeometryReader { geo in251 let w = geo.size.width, h = geo.size.height252 let strength: Double = scheme == .dark ? 0.14 : 0.20253 if reduceMotion {254 blobs(w: w, h: h, t: 0, strength: strength)255 } else {256 TimelineView(.animation(minimumInterval: 1.0 / 20)) { tl in257 let t = tl.date.timeIntervalSinceReferenceDate / 14258 blobs(w: w, h: h, t: t, strength: strength)259 }260 }261 }262 .allowsHitTesting(false)263 .accessibilityHidden(true)264 }265266 private func blobs(w: CGFloat, h: CGFloat, t: Double, strength: Double) -> some View {267 ZStack {268 Circle()269 .fill(KATheme.lime.opacity(strength))270 .frame(width: w * 0.9)271 .blur(radius: 70)272 .position(x: w * (0.75 + 0.18 * CGFloat(sin(t))),273 y: h * (0.10 + 0.08 * CGFloat(cos(t * 1.3))))274 Circle()275 .fill(KATheme.green.opacity(strength * 0.7))276 .frame(width: w * 0.8)277 .blur(radius: 80)278 .position(x: w * (0.12 + 0.15 * CGFloat(cos(t * 0.8))),279 y: h * (0.45 + 0.10 * CGFloat(sin(t * 1.1))))280 }281 }282}283