Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// HomeView.swift — l'accueil LÉGENDAIRE : héros éditorial (kicker mono,3// salutation surlignée lime, ticker encre en marquee du pouls live), pouls à4// chiffres roulants, suggestions contextuelles avec du VRAI contenu en direct,5// cartes qui « poppent » au défilement, fond aurora discret.6import SwiftUI78struct HomeView: View {9 @State private var featured: [(universe: Universe, items: [KAItem])] = []10 @State private var pulse: [(Universe, Int)] = []11 @State private var loading = true12 @AppStorage("ka.favUniverses") private var favUniversesRaw = ""13 @EnvironmentObject private var favorites: FavoritesStore14 @EnvironmentObject private var recents: RecentsStore15 @State private var editorialItems: [String: [KAItem]] = [:]16 @StateObject private var perso = PersonalizationStore.shared17 @State private var forYou: [ForYouEntry] = []18 @Environment(\.colorScheme) private var scheme1920 var body: some View {21 NavigationStack {22 ScrollView {23 VStack(alignment: .leading, spacing: 24) {24 heroCard25 if !forYou.isEmpty {26 ForYouSection(entries: forYou)27 }28 launcher29 pulseSection30 resumeStrip31 spotlight32 ForEach(featured, id: \.universe.id) { section in33 featuredSection(section.universe,34 section.items.filter { $0.id != spotlightPick?.1.id })35 }36 editorialSection37 if loading && featured.isEmpty {38 ProgressView("KA prépare votre accueil…")39 .frame(maxWidth: .infinity).padding(40)40 }41 disclaimer42 }43 .padding(16)44 }45 .background {46 ZStack {47 KATheme.paper(scheme)48 KAAurora()49 }50 .ignoresSafeArea()51 }52 .navigationTitle("")53 .toolbar {54 ToolbarItem(placement: .topBarLeading) {55 GroupeKAMark(size: 16)56 }57 }58 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }59 .navigationDestination(for: String.self) { id in60 if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) }61 }62 .refreshable { await load() }63 .task { await load() }64 }65 }6667 // MARK: sous-vues6869 private var greeting: (title: String, sub: String) {70 let h = Calendar.current.component(.hour, from: Date())71 let weekend = Calendar.current.isDateInWeekend(Date())72 switch h {73 case 5..<12: return ("Bon matin ☀️", weekend ? "On planifie la fin de semaine ?" : "L'écosystème a travaillé toute la nuit.")74 case 12..<17: return ("Bon après-midi 👋", "Tout le Québec, dans votre poche.")75 case 17..<22: return ("Bonsoir 🌆", weekend ? "Resto ? Sortie ? KA a des idées." : "On soupe où ce soir ?")76 default: return ("Bonne nuit 🌙", "Les connecteurs veillent — revenez demain matin.")77 }78 }7980 // MARK: héros encre — la pièce maîtresse de l'accueil8182 private var dateKicker: String {83 Date.now.formatted(.dateTime.weekday(.wide).day().month(.wide)84 .locale(Locale(identifier: "fr_CA"))).uppercased() + " · QUÉBEC EN DIRECT"85 }8687 private var heroCard: some View {88 VStack(alignment: .leading, spacing: 13) {89 HStack(spacing: 8) {90 Rectangle().fill(KATheme.lime).frame(width: 8, height: 8)91 Text(dateKicker)92 .font(KAFont.mono(9.5))93 .kerning(1.1)94 .foregroundStyle(KATheme.inkDark.opacity(0.75))95 .lineLimit(1)96 .minimumScaleFactor(0.8)97 Spacer(minLength: 0)98 HStack(spacing: 5) {99 Circle().fill(KATheme.lime).frame(width: 6, height: 6)100 Text("LIVE").font(KAFont.mono(9)).foregroundStyle(KATheme.lime)101 }102 }103 Text(greeting.title)104 .font(KAFont.display(34))105 .foregroundStyle(.white)106 Text(greeting.sub)107 .font(.subheadline)108 .foregroundStyle(KATheme.inkDark.opacity(0.72))109110 // la pilule de recherche — LE geste central de la super-app111 Button {112 Haptics.tap()113 NotificationCenter.default.post(name: .kaSwitchTab, object: 1)114 } label: {115 HStack(spacing: 10) {116 Image(systemName: "magnifyingglass")117 .font(.subheadline.weight(.bold))118 .foregroundStyle(KATheme.green)119 Text("Chercher dans tout le Québec…")120 .font(.subheadline.weight(.semibold))121 .foregroundStyle(KATheme.inkLight.opacity(0.7))122 Spacer(minLength: 0)123 Text("12 UNIVERS")124 .font(KAFont.mono(8.5))125 .foregroundStyle(.white)126 .padding(.horizontal, 8).padding(.vertical, 4)127 .background(KATheme.green, in: Capsule())128 }129 .padding(.horizontal, 14)130 .frame(height: 46)131 .background(.white, in: Capsule())132 }133 .buttonStyle(KAPressStyle())134 .accessibilityLabel("Chercher dans les 12 univers du Québec")135136 // le pouls live qui défile, intégré au héros137 KATicker(items: tickerItems)138 }139 .padding(18)140 .background {141 ZStack(alignment: .topTrailing) {142 RoundedRectangle(cornerRadius: 22, style: .continuous)143 .fill(KATheme.inkLight)144 Circle().fill(KATheme.lime.opacity(0.20))145 .frame(width: 240).blur(radius: 60)146 .offset(x: 70, y: -80)147 Text("KA")148 .font(KAFont.display(130))149 .foregroundStyle(.white.opacity(0.05))150 .offset(x: 6, y: -28)151 .accessibilityHidden(true)152 }153 .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))154 }155 .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous)156 .strokeBorder(.white.opacity(0.09), lineWidth: 1))157 .background(RoundedRectangle(cornerRadius: 22, style: .continuous)158 .fill(KATheme.lime.opacity(scheme == .dark ? 0.30 : 0.9))159 .offset(x: 6, y: 6))160 }161162 private var tickerItems: [String] {163 pulse.prefix(8).map {164 "\($0.1.formatted(.number.locale(Locale(identifier: "fr_CA")))) \($0.0.unit)"165 }166 }167168 // MARK: rampe de lancement des 12 univers (favoris d'abord)169170 private var launcherUniverses: [Universe] {171 let favs = favUniversesRaw.split(separator: ",").map(String.init)172 let favU = favs.compactMap(Ecosystem.universe)173 return favU + Ecosystem.all.filter { !favs.contains($0.id) }174 }175176 private var launcher: some View {177 VStack(alignment: .leading, spacing: 10) {178 UniverseSectionHeader(title: "Vos univers", accent: KATheme.green, detail: "12")179 ScrollView(.horizontal, showsIndicators: false) {180 HStack(spacing: 13) {181 ForEach(launcherUniverses) { u in182 NavigationLink(value: u.id) {183 VStack(spacing: 7) {184 KALogo(universe: u, size: 44)185 .padding(10)186 .background(KATheme.surface(scheme),187 in: RoundedRectangle(cornerRadius: 15, style: .continuous))188 .overlay(RoundedRectangle(cornerRadius: 15, style: .continuous)189 .strokeBorder(KATheme.ink(scheme).opacity(0.25), lineWidth: 1.2))190 .background(RoundedRectangle(cornerRadius: 15, style: .continuous)191 .fill(u.accent.opacity(scheme == .dark ? 0.45 : 0.95))192 .offset(x: 3.5, y: 3.5))193 Text(u.name)194 .font(.caption2.weight(.bold))195 .foregroundStyle(KATheme.ink(scheme))196 .lineLimit(1)197 }198 }199 .buttonStyle(KAPressStyle())200 .kaScrollPop()201 }202 }203 .padding(.vertical, 5)204 .padding(.horizontal, 2)205 }206 }207 }208209 // MARK: le pouls — compteurs roulants avec les logos officiels210211 private var pulseSection: some View {212 VStack(alignment: .leading, spacing: 10) {213 UniverseSectionHeader(title: "Le pouls de l'écosystème", accent: KATheme.green,214 detail: "EN DIRECT")215 // mosaïque dashboard : les 6 plus gros compteurs, en tuiles franches216 LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),217 GridItem(.flexible(), spacing: 12)], spacing: 12) {218 ForEach(pulse.prefix(6), id: \.0.id) { (u, n) in219 NavigationLink(value: u.id) {220 VStack(alignment: .leading, spacing: 6) {221 HStack {222 KALogo(universe: u, size: 26)223 Spacer()224 Circle().fill(u.accent).frame(width: 7, height: 7)225 }226 KACountUp(value: n, font: KAFont.display(21), color: u.accent)227 Text(u.unit.uppercased())228 .font(KAFont.mono(8))229 .foregroundStyle(.secondary)230 .lineLimit(1)231 .minimumScaleFactor(0.8)232 }233 .padding(12)234 .frame(maxWidth: .infinity, alignment: .leading)235 .kaCard(accent: u.accent)236 }237 .buttonStyle(KAPressStyle())238 .kaScrollPop(axis: .vertical)239 }240 }241 if pulse.isEmpty {242 Text("Le pouls de l'écosystème…")243 .font(.caption).foregroundStyle(.tertiary).padding(10)244 }245 }246 .accessibilityLabel("Le pouls de l'écosystème en direct")247 }248249 // MARK: le choix du moment — vitrine pleine largeur250251 private var spotlightPick: (Universe, KAItem)? {252 for s in featured {253 if let it = s.items.first(where: { $0.imageURL != nil }) { return (s.universe, it) }254 }255 return nil256 }257258 @ViewBuilder259 private var spotlight: some View {260 if let (u, star) = spotlightPick {261 VStack(alignment: .leading, spacing: 10) {262 UniverseSectionHeader(title: "Le choix du moment", accent: u.accent, detail: u.name)263 NavigationLink(value: star) {264 UniverseShowcaseCard(item: star, universe: u)265 }266 .buttonStyle(KAPressStyle())267 .kaScrollPop(axis: .vertical)268 }269 }270 }271272 private func featuredSection(_ u: Universe, _ items: [KAItem]) -> some View {273 VStack(alignment: .leading, spacing: 10) {274 HStack(spacing: 8) {275 KALogo(universe: u, size: 24)276 KAWordmark(universe: u, size: 17)277 Spacer()278 NavigationLink(value: u.id) {279 Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent)280 }281 }282 ScrollView(.horizontal, showsIndicators: false) {283 HStack(spacing: 12) {284 ForEach(items.prefix(8)) { item in285 NavigationLink(value: item) {286 VStack(alignment: .leading, spacing: 0) {287 KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol)288 .frame(width: 216, height: 118)289 .clipped()290 VStack(alignment: .leading, spacing: 4) {291 Text(item.title).font(.subheadline.weight(.semibold))292 .lineLimit(2, reservesSpace: true)293 .multilineTextAlignment(.leading)294 HStack {295 if let p = item.priceLabel {296 Text(p).font(.system(.caption, design: .rounded).weight(.bold))297 .foregroundStyle(u.accent)298 .lineLimit(1)299 }300 Spacer()301 if let c = item.city {302 Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1)303 }304 }305 }306 .padding(10)307 }308 .frame(width: 216, alignment: .topLeading)309 .kaCard(accent: u.accent)310 }311 .buttonStyle(KAPressStyle())312 .kaScrollPop()313 }314 }315 .scrollTargetLayout()316 .padding(.vertical, 4)317 }318 .scrollTargetBehavior(.viewAligned)319 }320 }321322 /// Reprendre : derniers consultés + favoris récents + recherches sauvegardées avec alertes323 @ViewBuilder324 private var resumeStrip: some View {325 let hasContent = !recents.viewed.isEmpty || !favorites.allItems.isEmpty || !recents.savedSearches.isEmpty326 if hasContent {327 VStack(alignment: .leading, spacing: 10) {328 Text("Reprendre").font(.headline)329 ScrollView(.horizontal, showsIndicators: false) {330 HStack(spacing: 10) {331 ForEach(recents.savedSearches.prefix(3)) { s in332 NavigationLink(value: s.universeID) {333 HStack(spacing: 6) {334 Image(systemName: "bell.badge").font(.caption)335 Text(s.name).font(.caption.weight(.bold)).lineLimit(1)336 if s.newCount > 0 {337 Text("+\(s.newCount)")338 .font(.system(size: 10, design: .monospaced).weight(.bold))339 .padding(.horizontal, 6).padding(.vertical, 2)340 .background(.red, in: Capsule())341 .foregroundStyle(.white)342 }343 }344 .padding(.horizontal, 12).padding(.vertical, 9)345 .kaCard()346 }347 .buttonStyle(KAPressStyle())348 }349 ForEach(recents.viewed.prefix(6)) { item in350 NavigationLink(value: item) {351 HStack(spacing: 8) {352 if let img = item.imageURL {353 KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray)354 .frame(width: 34, height: 34)355 .clipShape(RoundedRectangle(cornerRadius: 7))356 } else {357 Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)358 }359 Text(item.title).font(.caption.weight(.semibold)).lineLimit(1)360 }361 .padding(.horizontal, 10).padding(.vertical, 7)362 .kaCard(accent: Ecosystem.universe(item.universeID)?.accent)363 }364 .buttonStyle(KAPressStyle())365 }366 }367 }368 }369 }370 }371372 /// Collections éditoriales : de vraies requêtes, du vrai contenu373 @ViewBuilder374 private var editorialSection: some View {375 ForEach(Editorial.collections) { c in376 if let items = editorialItems[c.id], !items.isEmpty,377 let u = Ecosystem.universe(c.universeID) {378 VStack(alignment: .leading, spacing: 10) {379 HStack(spacing: 8) {380 Image(systemName: c.symbol).foregroundStyle(u.accent)381 VStack(alignment: .leading, spacing: 0) {382 Text(c.title).font(.headline)383 Text(c.subtitle).font(.caption).foregroundStyle(.secondary)384 }385 Spacer()386 NavigationLink(value: u.id) {387 Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent)388 }389 }390 ScrollView(.horizontal, showsIndicators: false) {391 HStack(spacing: 12) {392 ForEach(items.prefix(8)) { item in393 NavigationLink(value: item) {394 VStack(alignment: .leading, spacing: 0) {395 KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol)396 .frame(width: 170, height: 96).clipped()397 VStack(alignment: .leading, spacing: 2) {398 Text(item.title).font(.caption.weight(.semibold))399 .lineLimit(2, reservesSpace: true)400 if let p = item.priceLabel {401 Text(p).font(.system(.caption2, design: .rounded).weight(.bold))402 .foregroundStyle(u.accent)403 }404 }405 .padding(9)406 }407 .frame(width: 170, alignment: .topLeading)408 .kaCard(accent: u.accent)409 }410 .buttonStyle(KAPressStyle())411 .kaScrollPop()412 }413 }414 .scrollTargetLayout()415 .padding(.vertical, 4)416 }417 .scrollTargetBehavior(.viewAligned)418 }419 }420 }421 }422423 private var disclaimer: some View {424 Text(Ecosystem.disclaimer)425 .font(.caption2).foregroundStyle(.tertiary)426 .padding(.top, 8)427 }428429 // MARK: données430431 /// Univers mis en avant : affinité apprise > favoris d'onboarding > heure.432 private var contextualUniverses: [Universe] {433 let h = Calendar.current.component(.hour, from: Date())434 let weekend = Calendar.current.isDateInWeekend(Date())435 var ids: [String]436 switch h {437 case 5..<11: ids = ["job-ka", "food-ka", "immo-ka"]438 case 11..<14: ids = ["resto-ka", "food-ka", "sorti-ka"]439 case 14..<17: ids = weekend ? ["sorti-ka", "resto-ka", "auto-ka"] : ["immo-ka", "auto-ka", "lou-ka"]440 case 17..<23: ids = ["resto-ka", "sorti-ka", "crea-ka"]441 default: ids = ["sorti-ka", "crea-ka", "lou-ka"]442 }443 let favs = favUniversesRaw.split(separator: ",").map(String.init)444 for f in favs.reversed() where !ids.contains(f) { ids.insert(f, at: 0) }445 if perso.enabled, perso.profile.hasSignals {446 for u in perso.profile.topUniverses(2).reversed() where !ids.prefix(4).contains(u) {447 ids.insert(u, at: 0)448 }449 }450 return ids.prefix(4).compactMap(Ecosystem.universe).filter { $0.map != nil }451 }452453 private func load() async {454 loading = true455 let universes = contextualUniverses456 var found: [String: [KAItem]] = [:]457 await withTaskGroup(of: (String, [KAItem]).self) { group in458 for u in universes {459 group.addTask { (u.id, (try? await UniverseService.fetch(u, limit: 8)) ?? []) }460 }461 for u in Ecosystem.all where u.statTotalKeys.first != nil {462 _ = u // pouls géré plus bas463 }464 for await (id, items) in group { found[id] = items }465 }466 featured = universes.compactMap { u in467 let items = found[u.id] ?? []468 return items.isEmpty ? nil : (u, items)469 }470 loading = false471 // feed « Pour vous » — seulement avec un vrai profil (jamais de fausse perso)472 if perso.enabled, perso.profile.hasSignals {473 let seen = Set(recents.viewed.prefix(12).map(\.id))474 let feed = await RecommendationService.feed(profile: perso.profile, excluding: seen)475 withAnimation(.snappy) { forYou = feed }476 } else {477 forYou = []478 }479 // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil480 var counts: [(Universe, Int)] = []481 await withTaskGroup(of: (String, Int?).self) { group in482 for u in Ecosystem.all {483 group.addTask { (u.id, await UniverseService.liveTotal(u)) }484 }485 for await (id, n) in group {486 if let n, let u = Ecosystem.universe(id) { counts.append((u, n)) }487 }488 }489 pulse = counts.sorted { $0.1 > $1.1 }490 // collections éditoriales (requêtes réelles)491 await withTaskGroup(of: (String, [KAItem]).self) { group in492 for c in Editorial.collections {493 if let u = Ecosystem.universe(c.universeID) {494 group.addTask {495 (c.id, (try? await UniverseService.fetch(u, query: c.query, limit: 8, params: c.params)) ?? [])496 }497 }498 }499 for await (id, items) in group { editorialItems[id] = items }500 }501 }502}503