SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
11.1 KB · 283 lines swift
Raw Blame History
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