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 @Environment(\.colorScheme) private var scheme1718 var body: some View {19 NavigationStack {20 ScrollView {21 VStack(alignment: .leading, spacing: 24) {22 heroCard23 launcher24 pulseSection25 resumeStrip26 spotlight27 ForEach(featured, id: \.universe.id) { section in28 featuredSection(section.universe,29 section.items.filter { $0.id != spotlightPick?.1.id })30 }31 editorialSection32 if loading && featured.isEmpty {33 ProgressView("KA prépare votre accueil…")34 .frame(maxWidth: .infinity).padding(40)35 }36 disclaimer37 }38 .padding(16)39 }40 .background {41 ZStack {42 KATheme.paper(scheme)43 KAAurora()44 }45 .ignoresSafeArea()46 }47 .navigationTitle("")48 .toolbar {49 ToolbarItem(placement: .topBarLeading) {50 GroupeKAMark(size: 16)51 }52 }53 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }54 .navigationDestination(for: String.self) { id in55 if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) }56 }57 .refreshable { await load() }58 .task { await load() }59 }60 }6162 // MARK: sous-vues6364 private var greeting: (title: String, sub: String) {65 let h = Calendar.current.component(.hour, from: Date())66 let weekend = Calendar.current.isDateInWeekend(Date())67 switch h {68 case 5..<12: return ("Bon matin ☀️", weekend ? "On planifie la fin de semaine ?" : "L'écosystème a travaillé toute la nuit.")69 case 12..<17: return ("Bon après-midi 👋", "Tout le Québec, dans votre poche.")70 case 17..<22: return ("Bonsoir 🌆", weekend ? "Resto ? Sortie ? KA a des idées." : "On soupe où ce soir ?")71 default: return ("Bonne nuit 🌙", "Les connecteurs veillent — revenez demain matin.")72 }73 }7475 // MARK: héros encre — la pièce maîtresse de l'accueil7677 private var dateKicker: String {78 Date.now.formatted(.dateTime.weekday(.wide).day().month(.wide)79 .locale(Locale(identifier: "fr_CA"))).uppercased() + " · QUÉBEC EN DIRECT"80 }8182 private var heroCard: some View {83 VStack(alignment: .leading, spacing: 13) {84 HStack(spacing: 8) {85 Rectangle().fill(KATheme.lime).frame(width: 8, height: 8)86 Text(dateKicker)87 .font(KAFont.mono(9.5))88 .kerning(1.1)89 .foregroundStyle(KATheme.inkDark.opacity(0.75))90 .lineLimit(1)91 .minimumScaleFactor(0.8)92 Spacer(minLength: 0)93 HStack(spacing: 5) {94 Circle().fill(KATheme.lime).frame(width: 6, height: 6)95 Text("LIVE").font(KAFont.mono(9)).foregroundStyle(KATheme.lime)96 }97 }98 Text(greeting.title)99 .font(KAFont.display(34))100 .foregroundStyle(.white)101 Text(greeting.sub)102 .font(.subheadline)103 .foregroundStyle(KATheme.inkDark.opacity(0.72))104105 // la pilule de recherche — LE geste central de la super-app106 Button {107 Haptics.tap()108 NotificationCenter.default.post(name: .kaSwitchTab, object: 1)109 } label: {110 HStack(spacing: 10) {111 Image(systemName: "magnifyingglass")112 .font(.subheadline.weight(.bold))113 .foregroundStyle(KATheme.green)114 Text("Chercher dans tout le Québec…")115 .font(.subheadline.weight(.semibold))116 .foregroundStyle(KATheme.inkLight.opacity(0.7))117 Spacer(minLength: 0)118 Text("12 UNIVERS")119 .font(KAFont.mono(8.5))120 .foregroundStyle(.white)121 .padding(.horizontal, 8).padding(.vertical, 4)122 .background(KATheme.green, in: Capsule())123 }124 .padding(.horizontal, 14)125 .frame(height: 46)126 .background(.white, in: Capsule())127 }128 .buttonStyle(KAPressStyle())129 .accessibilityLabel("Chercher dans les 12 univers du Québec")130131 // le pouls live qui défile, intégré au héros132 KATicker(items: tickerItems)133 }134 .padding(18)135 .background {136 ZStack(alignment: .topTrailing) {137 RoundedRectangle(cornerRadius: 22, style: .continuous)138 .fill(KATheme.inkLight)139 Circle().fill(KATheme.lime.opacity(0.20))140 .frame(width: 240).blur(radius: 60)141 .offset(x: 70, y: -80)142 Text("KA")143 .font(KAFont.display(130))144 .foregroundStyle(.white.opacity(0.05))145 .offset(x: 6, y: -28)146 .accessibilityHidden(true)147 }148 .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))149 }150 .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous)151 .strokeBorder(.white.opacity(0.09), lineWidth: 1))152 .background(RoundedRectangle(cornerRadius: 22, style: .continuous)153 .fill(KATheme.lime.opacity(scheme == .dark ? 0.30 : 0.9))154 .offset(x: 6, y: 6))155 }156157 private var tickerItems: [String] {158 pulse.prefix(8).map {159 "\($0.1.formatted(.number.locale(Locale(identifier: "fr_CA")))) \($0.0.unit)"160 }161 }162163 // MARK: rampe de lancement des 12 univers (favoris d'abord)164165 private var launcherUniverses: [Universe] {166 let favs = favUniversesRaw.split(separator: ",").map(String.init)167 let favU = favs.compactMap(Ecosystem.universe)168 return favU + Ecosystem.all.filter { !favs.contains($0.id) }169 }170171 private var launcher: some View {172 VStack(alignment: .leading, spacing: 10) {173 UniverseSectionHeader(title: "Vos univers", accent: KATheme.green, detail: "12")174 ScrollView(.horizontal, showsIndicators: false) {175 HStack(spacing: 13) {176 ForEach(launcherUniverses) { u in177 NavigationLink(value: u.id) {178 VStack(spacing: 7) {179 KALogo(universe: u, size: 44)180 .padding(10)181 .background(KATheme.surface(scheme),182 in: RoundedRectangle(cornerRadius: 15, style: .continuous))183 .overlay(RoundedRectangle(cornerRadius: 15, style: .continuous)184 .strokeBorder(KATheme.ink(scheme).opacity(0.25), lineWidth: 1.2))185 .background(RoundedRectangle(cornerRadius: 15, style: .continuous)186 .fill(u.accent.opacity(scheme == .dark ? 0.45 : 0.95))187 .offset(x: 3.5, y: 3.5))188 Text(u.name)189 .font(.caption2.weight(.bold))190 .foregroundStyle(KATheme.ink(scheme))191 .lineLimit(1)192 }193 }194 .buttonStyle(KAPressStyle())195 .kaScrollPop()196 }197 }198 .padding(.vertical, 5)199 .padding(.horizontal, 2)200 }201 }202 }203204 // MARK: le pouls — compteurs roulants avec les logos officiels205206 private var pulseSection: some View {207 VStack(alignment: .leading, spacing: 10) {208 UniverseSectionHeader(title: "Le pouls de l'écosystème", accent: KATheme.green,209 detail: "EN DIRECT")210 ScrollView(.horizontal, showsIndicators: false) {211 HStack(spacing: 10) {212 ForEach(pulse, id: \.0.id) { (u, n) in213 NavigationLink(value: u.id) {214 HStack(spacing: 10) {215 KALogo(universe: u, size: 30)216 VStack(alignment: .leading, spacing: 1) {217 KACountUp(value: n, font: KAFont.display(17), color: u.accent)218 Text(u.unit.uppercased())219 .font(KAFont.mono(8))220 .foregroundStyle(.secondary)221 .lineLimit(1)222 }223 }224 .padding(.horizontal, 13).padding(.vertical, 10)225 .kaCard(accent: u.accent)226 }227 .buttonStyle(.plain)228 .kaScrollPop()229 }230 if pulse.isEmpty {231 Text("Le pouls de l'écosystème…")232 .font(.caption).foregroundStyle(.tertiary).padding(10)233 }234 }235 .padding(.vertical, 4)236 }237 }238 .accessibilityLabel("Le pouls de l'écosystème en direct")239 }240241 // MARK: le choix du moment — vitrine pleine largeur242243 private var spotlightPick: (Universe, KAItem)? {244 for s in featured {245 if let it = s.items.first(where: { $0.imageURL != nil }) { return (s.universe, it) }246 }247 return nil248 }249250 @ViewBuilder251 private var spotlight: some View {252 if let (u, star) = spotlightPick {253 VStack(alignment: .leading, spacing: 10) {254 UniverseSectionHeader(title: "Le choix du moment", accent: u.accent, detail: u.name)255 NavigationLink(value: star) {256 UniverseShowcaseCard(item: star, universe: u)257 }258 .buttonStyle(KAPressStyle())259 .kaScrollPop(axis: .vertical)260 }261 }262 }263264 private func featuredSection(_ u: Universe, _ items: [KAItem]) -> some View {265 VStack(alignment: .leading, spacing: 10) {266 HStack(spacing: 8) {267 KALogo(universe: u, size: 24)268 KAWordmark(universe: u, size: 17)269 Spacer()270 NavigationLink(value: u.id) {271 Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent)272 }273 }274 ScrollView(.horizontal, showsIndicators: false) {275 HStack(spacing: 12) {276 ForEach(items.prefix(8)) { item in277 NavigationLink(value: item) {278 VStack(alignment: .leading, spacing: 0) {279 KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol)280 .frame(width: 216, height: 118)281 .clipped()282 VStack(alignment: .leading, spacing: 4) {283 Text(item.title).font(.subheadline.weight(.semibold))284 .lineLimit(2, reservesSpace: true)285 .multilineTextAlignment(.leading)286 HStack {287 if let p = item.priceLabel {288 Text(p).font(.system(.caption, design: .rounded).weight(.bold))289 .foregroundStyle(u.accent)290 .lineLimit(1)291 }292 Spacer()293 if let c = item.city {294 Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1)295 }296 }297 }298 .padding(10)299 }300 .frame(width: 216, alignment: .topLeading)301 .kaCard(accent: u.accent)302 }303 .buttonStyle(KAPressStyle())304 .kaScrollPop()305 }306 }307 .scrollTargetLayout()308 .padding(.vertical, 4)309 }310 .scrollTargetBehavior(.viewAligned)311 }312 }313314 /// Reprendre : derniers consultés + favoris récents + recherches sauvegardées avec alertes315 @ViewBuilder316 private var resumeStrip: some View {317 let hasContent = !recents.viewed.isEmpty || !favorites.allItems.isEmpty || !recents.savedSearches.isEmpty318 if hasContent {319 VStack(alignment: .leading, spacing: 10) {320 Text("Reprendre").font(.headline)321 ScrollView(.horizontal, showsIndicators: false) {322 HStack(spacing: 10) {323 ForEach(recents.savedSearches.prefix(3)) { s in324 NavigationLink(value: s.universeID) {325 HStack(spacing: 6) {326 Image(systemName: "bell.badge").font(.caption)327 Text(s.name).font(.caption.weight(.bold)).lineLimit(1)328 if s.newCount > 0 {329 Text("+\(s.newCount)")330 .font(.system(size: 10, design: .monospaced).weight(.bold))331 .padding(.horizontal, 6).padding(.vertical, 2)332 .background(.red, in: Capsule())333 .foregroundStyle(.white)334 }335 }336 .padding(.horizontal, 12).padding(.vertical, 9)337 .kaCard()338 }339 .buttonStyle(KAPressStyle())340 }341 ForEach(recents.viewed.prefix(6)) { item in342 NavigationLink(value: item) {343 HStack(spacing: 8) {344 if let img = item.imageURL {345 KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray)346 .frame(width: 34, height: 34)347 .clipShape(RoundedRectangle(cornerRadius: 7))348 } else {349 Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)350 }351 Text(item.title).font(.caption.weight(.semibold)).lineLimit(1)352 }353 .padding(.horizontal, 10).padding(.vertical, 7)354 .kaCard(accent: Ecosystem.universe(item.universeID)?.accent)355 }356 .buttonStyle(KAPressStyle())357 }358 }359 }360 }361 }362 }363364 /// Collections éditoriales : de vraies requêtes, du vrai contenu365 @ViewBuilder366 private var editorialSection: some View {367 ForEach(Editorial.collections) { c in368 if let items = editorialItems[c.id], !items.isEmpty,369 let u = Ecosystem.universe(c.universeID) {370 VStack(alignment: .leading, spacing: 10) {371 HStack(spacing: 8) {372 Image(systemName: c.symbol).foregroundStyle(u.accent)373 VStack(alignment: .leading, spacing: 0) {374 Text(c.title).font(.headline)375 Text(c.subtitle).font(.caption).foregroundStyle(.secondary)376 }377 Spacer()378 NavigationLink(value: u.id) {379 Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent)380 }381 }382 ScrollView(.horizontal, showsIndicators: false) {383 HStack(spacing: 12) {384 ForEach(items.prefix(8)) { item in385 NavigationLink(value: item) {386 VStack(alignment: .leading, spacing: 0) {387 KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol)388 .frame(width: 170, height: 96).clipped()389 VStack(alignment: .leading, spacing: 2) {390 Text(item.title).font(.caption.weight(.semibold))391 .lineLimit(2, reservesSpace: true)392 if let p = item.priceLabel {393 Text(p).font(.system(.caption2, design: .rounded).weight(.bold))394 .foregroundStyle(u.accent)395 }396 }397 .padding(9)398 }399 .frame(width: 170, alignment: .topLeading)400 .kaCard(accent: u.accent)401 }402 .buttonStyle(KAPressStyle())403 .kaScrollPop()404 }405 }406 .scrollTargetLayout()407 .padding(.vertical, 4)408 }409 .scrollTargetBehavior(.viewAligned)410 }411 }412 }413 }414415 private var disclaimer: some View {416 Text(Ecosystem.disclaimer)417 .font(.caption2).foregroundStyle(.tertiary)418 .padding(.top, 8)419 }420421 // MARK: données422423 /// Univers mis en avant selon l'heure + les préférences d'onboarding.424 private var contextualUniverses: [Universe] {425 let h = Calendar.current.component(.hour, from: Date())426 let weekend = Calendar.current.isDateInWeekend(Date())427 var ids: [String]428 switch h {429 case 5..<11: ids = ["job-ka", "food-ka", "immo-ka"]430 case 11..<14: ids = ["resto-ka", "food-ka", "sorti-ka"]431 case 14..<17: ids = weekend ? ["sorti-ka", "resto-ka", "auto-ka"] : ["immo-ka", "auto-ka", "lou-ka"]432 case 17..<23: ids = ["resto-ka", "sorti-ka", "crea-ka"]433 default: ids = ["sorti-ka", "crea-ka", "lou-ka"]434 }435 let favs = favUniversesRaw.split(separator: ",").map(String.init)436 for f in favs.reversed() where !ids.contains(f) { ids.insert(f, at: 0) }437 return ids.prefix(4).compactMap(Ecosystem.universe).filter { $0.map != nil }438 }439440 private func load() async {441 loading = true442 let universes = contextualUniverses443 var found: [String: [KAItem]] = [:]444 await withTaskGroup(of: (String, [KAItem]).self) { group in445 for u in universes {446 group.addTask { (u.id, (try? await UniverseService.fetch(u, limit: 8)) ?? []) }447 }448 for u in Ecosystem.all where u.statTotalKeys.first != nil {449 _ = u // pouls géré plus bas450 }451 for await (id, items) in group { found[id] = items }452 }453 featured = universes.compactMap { u in454 let items = found[u.id] ?? []455 return items.isEmpty ? nil : (u, items)456 }457 loading = false458 // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil459 var counts: [(Universe, Int)] = []460 await withTaskGroup(of: (String, Int?).self) { group in461 for u in Ecosystem.all {462 group.addTask { (u.id, await UniverseService.liveTotal(u)) }463 }464 for await (id, n) in group {465 if let n, let u = Ecosystem.universe(id) { counts.append((u, n)) }466 }467 }468 pulse = counts.sorted { $0.1 > $1.1 }469 // collections éditoriales (requêtes réelles)470 await withTaskGroup(of: (String, [KAItem]).self) { group in471 for c in Editorial.collections {472 if let u = Ecosystem.universe(c.universeID) {473 group.addTask {474 (c.id, (try? await UniverseService.fetch(u, query: c.query, limit: 8, params: c.params)) ?? [])475 }476 }477 }478 for await (id, items) in group { editorialItems[id] = items }479 }480 }481}482