// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // HomeView.swift — l'accueil LÉGENDAIRE : héros éditorial (kicker mono, // salutation surlignée lime, ticker encre en marquee du pouls live), pouls à // chiffres roulants, suggestions contextuelles avec du VRAI contenu en direct, // cartes qui « poppent » au défilement, fond aurora discret. import SwiftUI struct HomeView: View { @State private var featured: [(universe: Universe, items: [KAItem])] = [] @State private var pulse: [(Universe, Int)] = [] @State private var loading = true @AppStorage("ka.favUniverses") private var favUniversesRaw = "" @EnvironmentObject private var favorites: FavoritesStore @EnvironmentObject private var recents: RecentsStore @State private var editorialItems: [String: [KAItem]] = [:] @StateObject private var perso = PersonalizationStore.shared @State private var forYou: [ForYouEntry] = [] @Environment(\.colorScheme) private var scheme var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 24) { heroCard if !forYou.isEmpty { ForYouSection(entries: forYou) } launcher pulseSection resumeStrip spotlight ForEach(featured, id: \.universe.id) { section in featuredSection(section.universe, section.items.filter { $0.id != spotlightPick?.1.id }) } editorialSection if loading && featured.isEmpty { ProgressView("KA prépare votre accueil…") .frame(maxWidth: .infinity).padding(40) } disclaimer } .padding(16) } .background { ZStack { KATheme.paper(scheme) KAAurora() } .ignoresSafeArea() } .navigationTitle("") .toolbar { ToolbarItem(placement: .topBarLeading) { GroupeKAMark(size: 16) } } .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) } .navigationDestination(for: String.self) { id in if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) } } .refreshable { await load() } .task { await load() } } } // MARK: sous-vues private var greeting: (title: String, sub: String) { let h = Calendar.current.component(.hour, from: Date()) let weekend = Calendar.current.isDateInWeekend(Date()) switch h { case 5..<12: return ("Bon matin ☀️", weekend ? "On planifie la fin de semaine ?" : "L'écosystème a travaillé toute la nuit.") case 12..<17: return ("Bon après-midi 👋", "Tout le Québec, dans votre poche.") case 17..<22: return ("Bonsoir 🌆", weekend ? "Resto ? Sortie ? KA a des idées." : "On soupe où ce soir ?") default: return ("Bonne nuit 🌙", "Les connecteurs veillent — revenez demain matin.") } } // MARK: héros encre — la pièce maîtresse de l'accueil private var dateKicker: String { Date.now.formatted(.dateTime.weekday(.wide).day().month(.wide) .locale(Locale(identifier: "fr_CA"))).uppercased() + " · QUÉBEC EN DIRECT" } private var heroCard: some View { VStack(alignment: .leading, spacing: 13) { HStack(spacing: 8) { Rectangle().fill(KATheme.lime).frame(width: 8, height: 8) Text(dateKicker) .font(KAFont.mono(9.5)) .kerning(1.1) .foregroundStyle(KATheme.inkDark.opacity(0.75)) .lineLimit(1) .minimumScaleFactor(0.8) Spacer(minLength: 0) HStack(spacing: 5) { Circle().fill(KATheme.lime).frame(width: 6, height: 6) Text("LIVE").font(KAFont.mono(9)).foregroundStyle(KATheme.lime) } } Text(greeting.title) .font(KAFont.display(34)) .foregroundStyle(.white) Text(greeting.sub) .font(.subheadline) .foregroundStyle(KATheme.inkDark.opacity(0.72)) // la pilule de recherche — LE geste central de la super-app Button { Haptics.tap() NotificationCenter.default.post(name: .kaSwitchTab, object: 1) } label: { HStack(spacing: 10) { Image(systemName: "magnifyingglass") .font(.subheadline.weight(.bold)) .foregroundStyle(KATheme.green) Text("Chercher dans tout le Québec…") .font(.subheadline.weight(.semibold)) .foregroundStyle(KATheme.inkLight.opacity(0.7)) Spacer(minLength: 0) Text("12 UNIVERS") .font(KAFont.mono(8.5)) .foregroundStyle(.white) .padding(.horizontal, 8).padding(.vertical, 4) .background(KATheme.green, in: Capsule()) } .padding(.horizontal, 14) .frame(height: 46) .background(.white, in: Capsule()) } .buttonStyle(KAPressStyle()) .accessibilityLabel("Chercher dans les 12 univers du Québec") // le pouls live qui défile, intégré au héros KATicker(items: tickerItems) } .padding(18) .background { ZStack(alignment: .topTrailing) { RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(KATheme.inkLight) Circle().fill(KATheme.lime.opacity(0.20)) .frame(width: 240).blur(radius: 60) .offset(x: 70, y: -80) Text("KA") .font(KAFont.display(130)) .foregroundStyle(.white.opacity(0.05)) .offset(x: 6, y: -28) .accessibilityHidden(true) } .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) } .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous) .strokeBorder(.white.opacity(0.09), lineWidth: 1)) .background(RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(KATheme.lime.opacity(scheme == .dark ? 0.30 : 0.9)) .offset(x: 6, y: 6)) } private var tickerItems: [String] { pulse.prefix(8).map { "\($0.1.formatted(.number.locale(Locale(identifier: "fr_CA")))) \($0.0.unit)" } } // MARK: rampe de lancement des 12 univers (favoris d'abord) private var launcherUniverses: [Universe] { let favs = favUniversesRaw.split(separator: ",").map(String.init) let favU = favs.compactMap(Ecosystem.universe) return favU + Ecosystem.all.filter { !favs.contains($0.id) } } private var launcher: some View { VStack(alignment: .leading, spacing: 10) { UniverseSectionHeader(title: "Vos univers", accent: KATheme.green, detail: "12") ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 13) { ForEach(launcherUniverses) { u in NavigationLink(value: u.id) { VStack(spacing: 7) { KALogo(universe: u, size: 44) .padding(10) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 15, style: .continuous) .strokeBorder(KATheme.ink(scheme).opacity(0.25), lineWidth: 1.2)) .background(RoundedRectangle(cornerRadius: 15, style: .continuous) .fill(u.accent.opacity(scheme == .dark ? 0.45 : 0.95)) .offset(x: 3.5, y: 3.5)) Text(u.name) .font(.caption2.weight(.bold)) .foregroundStyle(KATheme.ink(scheme)) .lineLimit(1) } } .buttonStyle(KAPressStyle()) .kaScrollPop() } } .padding(.vertical, 5) .padding(.horizontal, 2) } } } // MARK: le pouls — compteurs roulants avec les logos officiels private var pulseSection: some View { VStack(alignment: .leading, spacing: 10) { UniverseSectionHeader(title: "Le pouls de l'écosystème", accent: KATheme.green, detail: "EN DIRECT") // mosaïque dashboard : les 6 plus gros compteurs, en tuiles franches LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { ForEach(pulse.prefix(6), id: \.0.id) { (u, n) in NavigationLink(value: u.id) { VStack(alignment: .leading, spacing: 6) { HStack { KALogo(universe: u, size: 26) Spacer() Circle().fill(u.accent).frame(width: 7, height: 7) } KACountUp(value: n, font: KAFont.display(21), color: u.accent) Text(u.unit.uppercased()) .font(KAFont.mono(8)) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.8) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .kaCard(accent: u.accent) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) } } if pulse.isEmpty { Text("Le pouls de l'écosystème…") .font(.caption).foregroundStyle(.tertiary).padding(10) } } .accessibilityLabel("Le pouls de l'écosystème en direct") } // MARK: le choix du moment — vitrine pleine largeur private var spotlightPick: (Universe, KAItem)? { for s in featured { if let it = s.items.first(where: { $0.imageURL != nil }) { return (s.universe, it) } } return nil } @ViewBuilder private var spotlight: some View { if let (u, star) = spotlightPick { VStack(alignment: .leading, spacing: 10) { UniverseSectionHeader(title: "Le choix du moment", accent: u.accent, detail: u.name) NavigationLink(value: star) { UniverseShowcaseCard(item: star, universe: u) } .buttonStyle(KAPressStyle()) .kaScrollPop(axis: .vertical) } } } private func featuredSection(_ u: Universe, _ items: [KAItem]) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { KALogo(universe: u, size: 24) KAWordmark(universe: u, size: 17) Spacer() NavigationLink(value: u.id) { Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent) } } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(items.prefix(8)) { item in NavigationLink(value: item) { VStack(alignment: .leading, spacing: 0) { KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol) .frame(width: 216, height: 118) .clipped() VStack(alignment: .leading, spacing: 4) { Text(item.title).font(.subheadline.weight(.semibold)) .lineLimit(2, reservesSpace: true) .multilineTextAlignment(.leading) HStack { if let p = item.priceLabel { Text(p).font(.system(.caption, design: .rounded).weight(.bold)) .foregroundStyle(u.accent) .lineLimit(1) } Spacer() if let c = item.city { Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) } } } .padding(10) } .frame(width: 216, alignment: .topLeading) .kaCard(accent: u.accent) } .buttonStyle(KAPressStyle()) .kaScrollPop() } } .scrollTargetLayout() .padding(.vertical, 4) } .scrollTargetBehavior(.viewAligned) } } /// Reprendre : derniers consultés + favoris récents + recherches sauvegardées avec alertes @ViewBuilder private var resumeStrip: some View { let hasContent = !recents.viewed.isEmpty || !favorites.allItems.isEmpty || !recents.savedSearches.isEmpty if hasContent { VStack(alignment: .leading, spacing: 10) { Text("Reprendre").font(.headline) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(recents.savedSearches.prefix(3)) { s in NavigationLink(value: s.universeID) { HStack(spacing: 6) { Image(systemName: "bell.badge").font(.caption) Text(s.name).font(.caption.weight(.bold)).lineLimit(1) if s.newCount > 0 { Text("+\(s.newCount)") .font(.system(size: 10, design: .monospaced).weight(.bold)) .padding(.horizontal, 6).padding(.vertical, 2) .background(.red, in: Capsule()) .foregroundStyle(.white) } } .padding(.horizontal, 12).padding(.vertical, 9) .kaCard() } .buttonStyle(KAPressStyle()) } ForEach(recents.viewed.prefix(6)) { item in NavigationLink(value: item) { HStack(spacing: 8) { if let img = item.imageURL { KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray) .frame(width: 34, height: 34) .clipShape(RoundedRectangle(cornerRadius: 7)) } else { Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary) } Text(item.title).font(.caption.weight(.semibold)).lineLimit(1) } .padding(.horizontal, 10).padding(.vertical, 7) .kaCard(accent: Ecosystem.universe(item.universeID)?.accent) } .buttonStyle(KAPressStyle()) } } } } } } /// Collections éditoriales : de vraies requêtes, du vrai contenu @ViewBuilder private var editorialSection: some View { ForEach(Editorial.collections) { c in if let items = editorialItems[c.id], !items.isEmpty, let u = Ecosystem.universe(c.universeID) { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: c.symbol).foregroundStyle(u.accent) VStack(alignment: .leading, spacing: 0) { Text(c.title).font(.headline) Text(c.subtitle).font(.caption).foregroundStyle(.secondary) } Spacer() NavigationLink(value: u.id) { Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent) } } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(items.prefix(8)) { item in NavigationLink(value: item) { VStack(alignment: .leading, spacing: 0) { KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol) .frame(width: 170, height: 96).clipped() VStack(alignment: .leading, spacing: 2) { Text(item.title).font(.caption.weight(.semibold)) .lineLimit(2, reservesSpace: true) if let p = item.priceLabel { Text(p).font(.system(.caption2, design: .rounded).weight(.bold)) .foregroundStyle(u.accent) } } .padding(9) } .frame(width: 170, alignment: .topLeading) .kaCard(accent: u.accent) } .buttonStyle(KAPressStyle()) .kaScrollPop() } } .scrollTargetLayout() .padding(.vertical, 4) } .scrollTargetBehavior(.viewAligned) } } } } private var disclaimer: some View { Text(Ecosystem.disclaimer) .font(.caption2).foregroundStyle(.tertiary) .padding(.top, 8) } // MARK: données /// Univers mis en avant : affinité apprise > favoris d'onboarding > heure. private var contextualUniverses: [Universe] { let h = Calendar.current.component(.hour, from: Date()) let weekend = Calendar.current.isDateInWeekend(Date()) var ids: [String] switch h { case 5..<11: ids = ["job-ka", "food-ka", "immo-ka"] case 11..<14: ids = ["resto-ka", "food-ka", "sorti-ka"] case 14..<17: ids = weekend ? ["sorti-ka", "resto-ka", "auto-ka"] : ["immo-ka", "auto-ka", "lou-ka"] case 17..<23: ids = ["resto-ka", "sorti-ka", "crea-ka"] default: ids = ["sorti-ka", "crea-ka", "lou-ka"] } let favs = favUniversesRaw.split(separator: ",").map(String.init) for f in favs.reversed() where !ids.contains(f) { ids.insert(f, at: 0) } if perso.enabled, perso.profile.hasSignals { for u in perso.profile.topUniverses(2).reversed() where !ids.prefix(4).contains(u) { ids.insert(u, at: 0) } } return ids.prefix(4).compactMap(Ecosystem.universe).filter { $0.map != nil } } private func load() async { loading = true let universes = contextualUniverses var found: [String: [KAItem]] = [:] await withTaskGroup(of: (String, [KAItem]).self) { group in for u in universes { group.addTask { (u.id, (try? await UniverseService.fetch(u, limit: 8)) ?? []) } } for u in Ecosystem.all where u.statTotalKeys.first != nil { _ = u // pouls géré plus bas } for await (id, items) in group { found[id] = items } } featured = universes.compactMap { u in let items = found[u.id] ?? [] return items.isEmpty ? nil : (u, items) } loading = false // feed « Pour vous » — seulement avec un vrai profil (jamais de fausse perso) if perso.enabled, perso.profile.hasSignals { let seen = Set(recents.viewed.prefix(12).map(\.id)) let feed = await RecommendationService.feed(profile: perso.profile, excluding: seen) withAnimation(.snappy) { forYou = feed } } else { forYou = [] } // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil var counts: [(Universe, Int)] = [] await withTaskGroup(of: (String, Int?).self) { group in for u in Ecosystem.all { group.addTask { (u.id, await UniverseService.liveTotal(u)) } } for await (id, n) in group { if let n, let u = Ecosystem.universe(id) { counts.append((u, n)) } } } pulse = counts.sorted { $0.1 > $1.1 } // collections éditoriales (requêtes réelles) await withTaskGroup(of: (String, [KAItem]).self) { group in for c in Editorial.collections { if let u = Ecosystem.universe(c.universeID) { group.addTask { (c.id, (try? await UniverseService.fetch(u, query: c.query, limit: 8, params: c.params)) ?? []) } } } for await (id, items) in group { editorialItems[id] = items } } } }