// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // KAMotion.swift — le langage de mouvement v3 « légendaire » de la super-app : // dock flottant signature (encre + lime, ombre dure), ticker encre en marquee // (comme les héros des sites), compteurs à chiffres roulants, fond aurora // discret, transitions de défilement. Reduce Motion respecté partout. import SwiftUI // MARK: - Dock flottant (remplace la tab bar système) struct KADock: View { @Binding var tab: Int var alertCount: Int var onAgent: () -> Void @Namespace private var ns @Environment(\.colorScheme) private var scheme static let items: [(icon: String, label: String)] = [ ("house.fill", "Accueil"), ("magnifyingglass", "Recherche"), ("circle.grid.3x3.fill", "Explorer"), ("map.fill", "Carte"), ("location.north.fill", "Trajet"), ("person.crop.circle.fill", "Profil"), ] var body: some View { HStack(spacing: 2) { ForEach(Array(Self.items.enumerated()), id: \.offset) { i, item in Button { guard tab != i else { return } Haptics.tap() withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { tab = i } } label: { ZStack(alignment: .topTrailing) { Image(systemName: item.icon) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(tab == i ? KATheme.inkLight : KATheme.inkDark.opacity(0.72)) .frame(width: 43, height: 43) .background { if tab == i { Circle().fill(KATheme.lime) .matchedGeometryEffect(id: "ka.dock.active", in: ns) } } if i == Self.items.count - 1 && alertCount > 0 { Circle().fill(.red).frame(width: 8, height: 8) .offset(x: -5, y: 5) .accessibilityHidden(true) } } } .accessibilityLabel(item.label + (i == Self.items.count - 1 && alertCount > 0 ? ", \(alertCount) alertes" : "")) .accessibilityAddTraits(tab == i ? [.isSelected] : []) } Rectangle().fill(KATheme.inkDark.opacity(0.25)) .frame(width: 1, height: 24) .padding(.horizontal, 4) .accessibilityHidden(true) Button { Haptics.rigid() onAgent() } label: { Text("Ka") .font(KAFont.display(15)) .foregroundStyle(KATheme.lime) .frame(width: 43, height: 43) .background(Circle().strokeBorder(KATheme.lime, lineWidth: 1.5)) } .accessibilityLabel("Ouvrir KA Agent, l'assistant de l'écosystème") } .padding(6) .background(KATheme.inkLight, in: Capsule()) .overlay(Capsule().strokeBorder(.white.opacity(0.10), lineWidth: 1)) // signature Ka : ombre décalée DURE, lime en clair, feutrée en sombre .background( Capsule() .fill(scheme == .dark ? KATheme.lime.opacity(0.30) : KATheme.lime) .offset(x: 5, y: 5) ) .shadow(color: .black.opacity(0.22), radius: 14, y: 8) } } // MARK: - Ticker encre (marquee des héros de sites : mono lime qui défile) struct KATicker: View { let items: [String] @State private var textWidth: CGFloat = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private var line: String { let parts = items.isEmpty ? ["GROUPE KA — LE QUÉBEC EN DIRECT"] : items return parts.map { $0.uppercased() }.joined(separator: " ✦ ") + " ✦ " } var body: some View { Group { if reduceMotion { Text(line) .font(KAFont.mono(11)) .lineLimit(1) .padding(.horizontal, 10) .frame(maxWidth: .infinity, alignment: .leading) } else { // le marquee vit en OVERLAY d'un espace fixe : sa largeur réelle // (des milliers de points) ne se propage jamais à la mise en page Color.clear .frame(height: 15) .overlay(alignment: .leading) { TimelineView(.animation(minimumInterval: 1.0 / 30)) { tl in let t = tl.date.timeIntervalSinceReferenceDate let x = textWidth > 0 ? -CGFloat((t * 42).truncatingRemainder(dividingBy: textWidth)) : 0 HStack(spacing: 0) { tickerText .background(GeometryReader { g in Color.clear .onAppear { textWidth = g.size.width } .onChange(of: g.size.width) { _, w in textWidth = w } }) tickerText } .fixedSize() .offset(x: x) } } .clipped() } } .padding(.vertical, 9) .foregroundStyle(KATheme.lime) .background(KATheme.inkLight) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(KATheme.green.opacity(0.9)) .offset(x: 4, y: 4) ) .accessibilityElement(children: .ignore) .accessibilityLabel("Le pouls de l'écosystème : " + items.joined(separator: ", ")) } private var tickerText: some View { Text(line) .font(KAFont.mono(11)) .fixedSize() .padding(.leading, 2) } } // MARK: - Compteur à chiffres roulants (pouls, stats héros) struct KACountUp: View { let value: Int var font: Font = .system(.headline, design: .rounded).weight(.bold) var color: Color = .primary @State private var shown = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Text(shown.formatted(.number.locale(Locale(identifier: "fr_CA")))) .font(font) .foregroundStyle(color) .contentTransition(.numericText(value: Double(shown))) .onAppear { if reduceMotion { shown = value } else { withAnimation(.easeOut(duration: 1.1)) { shown = value } } } .onChange(of: value) { _, v in withAnimation(.easeOut(duration: 0.8)) { shown = v } } } } // MARK: - Surligneur lime animé (le « titre surligné » des sites) struct KAHighlighted: ViewModifier { @State private var on = false @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .padding(.horizontal, 7) .padding(.vertical, 1) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(KATheme.lime) .rotationEffect(.degrees(-1.4)) .scaleEffect(x: on ? 1 : 0.02, anchor: .leading) ) .foregroundStyle(KATheme.inkLight) .onAppear { if reduceMotion { on = true } else { withAnimation(.spring(response: 0.7, dampingFraction: 0.8).delay(0.25)) { on = true } } } } } extension View { /// Surligne le texte d'un marqueur lime qui se déploie (signature des sites). func kaHighlighted() -> some View { modifier(KAHighlighted()) } /// Pop de défilement : les cartes entrent/sortent avec échelle + fondu. func kaScrollPop(axis: Axis = .horizontal) -> some View { scrollTransition(.interactive, axis: axis) { content, phase in content .scaleEffect(phase.isIdentity ? 1 : 0.93) .opacity(phase.isIdentity ? 1 : 0.55) } } } // MARK: - Flux d'étiquettes (retour à la ligne naturel, façon chips des sites) struct KAFlow: Layout { var spacing: CGFloat = 8 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let maxW = proposal.width ?? .infinity var x: CGFloat = 0, y: CGFloat = 0, rowH: CGFloat = 0 for v in subviews { let s = v.sizeThatFits(.unspecified) if x + s.width > maxW, x > 0 { x = 0; y += rowH + spacing; rowH = 0 } x += s.width + spacing rowH = max(rowH, s.height) } return CGSize(width: maxW == .infinity ? max(0, x - spacing) : maxW, height: y + rowH) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { var x = bounds.minX, y = bounds.minY, rowH: CGFloat = 0 for v in subviews { let s = v.sizeThatFits(.unspecified) if x + s.width > bounds.maxX, x > bounds.minX { x = bounds.minX; y += rowH + spacing; rowH = 0 } v.place(at: CGPoint(x: x, y: y), proposal: .unspecified) x += s.width + spacing rowH = max(rowH, s.height) } } } // MARK: - Aurora — deux lueurs lime/vert qui dérivent, très discrètes struct KAAurora: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.colorScheme) private var scheme var body: some View { GeometryReader { geo in let w = geo.size.width, h = geo.size.height let strength: Double = scheme == .dark ? 0.14 : 0.20 if reduceMotion { blobs(w: w, h: h, t: 0, strength: strength) } else { TimelineView(.animation(minimumInterval: 1.0 / 20)) { tl in let t = tl.date.timeIntervalSinceReferenceDate / 14 blobs(w: w, h: h, t: t, strength: strength) } } } .allowsHitTesting(false) .accessibilityHidden(true) } private func blobs(w: CGFloat, h: CGFloat, t: Double, strength: Double) -> some View { ZStack { Circle() .fill(KATheme.lime.opacity(strength)) .frame(width: w * 0.9) .blur(radius: 70) .position(x: w * (0.75 + 0.18 * CGFloat(sin(t))), y: h * (0.10 + 0.08 * CGFloat(cos(t * 1.3)))) Circle() .fill(KATheme.green.opacity(strength * 0.7)) .frame(width: w * 0.8) .blur(radius: 80) .position(x: w * (0.12 + 0.15 * CGFloat(cos(t * 0.8))), y: h * (0.45 + 0.10 * CGFloat(sin(t * 1.1)))) } } }