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: 22) {22 header23 tickerStrip24 pulseStrip25 resumeStrip26 ForEach(featured, id: \.universe.id) { section in27 featuredSection(section.universe, section.items)28 }29 editorialSection30 if loading && featured.isEmpty {31 ProgressView("KA prépare votre accueil…")32 .frame(maxWidth: .infinity).padding(40)33 }34 disclaimer35 }36 .padding(16)37 }38 .background {39 ZStack {40 KATheme.paper(scheme)41 KAAurora()42 }43 .ignoresSafeArea()44 }45 .navigationTitle("")46 .toolbar {47 ToolbarItem(placement: .topBarLeading) {48 GroupeKAMark(size: 16)49 }50 }51 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }52 .navigationDestination(for: String.self) { id in53 if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) }54 }55 .refreshable { await load() }56 .task { await load() }57 }58 }5960 // MARK: sous-vues6162 private var greeting: (title: String, sub: String) {63 let h = Calendar.current.component(.hour, from: Date())64 let weekend = Calendar.current.isDateInWeekend(Date())65 switch h {66 case 5..<12: return ("Bon matin ☀️", weekend ? "On planifie la fin de semaine ?" : "L'écosystème a travaillé toute la nuit.")67 case 12..<17: return ("Bon après-midi 👋", "Tout le Québec, dans votre poche.")68 case 17..<22: return ("Bonsoir 🌆", weekend ? "Resto ? Sortie ? KA a des idées." : "On soupe où ce soir ?")69 default: return ("Bonne nuit 🌙", "Les connecteurs veillent — revenez demain matin.")70 }71 }7273 private var header: some View {74 VStack(alignment: .leading, spacing: 8) {75 // kicker mono — la micro-étiquette signature des sites76 HStack(spacing: 6) {77 Rectangle().fill(KATheme.lime).frame(width: 8, height: 8)78 Text("GROUPE KA — L'ÉCOSYSTÈME QUÉBÉCOIS")79 .font(KAFont.mono(10))80 .foregroundStyle(KATheme.ink2(scheme))81 }82 .accessibilityHidden(true)83 Text(greeting.title)84 .font(KAFont.display(34))85 .kaHighlighted()86 Text(greeting.sub).font(.subheadline).foregroundStyle(KATheme.ink2(scheme))87 }88 }8990 /// Ticker encre : le pouls live de l'écosystème qui défile, comme les héros des sites91 private var tickerStrip: some View {92 KATicker(items: pulse.prefix(8).map {93 "\($0.1.formatted(.number.locale(Locale(identifier: "fr_CA")))) \($0.0.unit)"94 })95 }9697 private var pulseStrip: some View {98 ScrollView(.horizontal, showsIndicators: false) {99 HStack(spacing: 10) {100 ForEach(pulse, id: \.0.id) { (u, n) in101 NavigationLink(value: u.id) {102 VStack(alignment: .leading, spacing: 3) {103 KACountUp(value: n,104 font: .system(.headline, design: .rounded).weight(.bold),105 color: u.accent)106 Text(u.unit).font(KAFont.mono(9)).textCase(.uppercase)107 .foregroundStyle(.secondary)108 }109 .padding(.horizontal, 13).padding(.vertical, 9)110 .kaCard(accent: u.accent)111 }112 .buttonStyle(.plain)113 .kaScrollPop()114 }115 if pulse.isEmpty {116 Text("Le pouls de l'écosystème…").font(.caption).foregroundStyle(.tertiary).padding(10)117 }118 }119 .padding(.vertical, 4)120 }121 .accessibilityLabel("Le pouls de l'écosystème en direct")122 }123124 private func featuredSection(_ u: Universe, _ items: [KAItem]) -> some View {125 VStack(alignment: .leading, spacing: 10) {126 HStack(spacing: 8) {127 KALogo(universe: u, size: 24)128 KAWordmark(universe: u, size: 17)129 Spacer()130 NavigationLink(value: u.id) {131 Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent)132 }133 }134 ScrollView(.horizontal, showsIndicators: false) {135 HStack(spacing: 12) {136 ForEach(items.prefix(8)) { item in137 NavigationLink(value: item) {138 VStack(alignment: .leading, spacing: 0) {139 KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol)140 .frame(width: 216, height: 118)141 .clipped()142 VStack(alignment: .leading, spacing: 4) {143 Text(item.title).font(.subheadline.weight(.semibold))144 .lineLimit(2, reservesSpace: true)145 .multilineTextAlignment(.leading)146 HStack {147 if let p = item.priceLabel {148 Text(p).font(.system(.caption, design: .rounded).weight(.bold))149 .foregroundStyle(u.accent)150 .lineLimit(1)151 }152 Spacer()153 if let c = item.city {154 Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1)155 }156 }157 }158 .padding(10)159 }160 .frame(width: 216, alignment: .topLeading)161 .kaCard(accent: u.accent)162 }163 .buttonStyle(KAPressStyle())164 .kaScrollPop()165 }166 }167 .scrollTargetLayout()168 .padding(.vertical, 4)169 }170 .scrollTargetBehavior(.viewAligned)171 }172 }173174 /// Reprendre : derniers consultés + favoris récents + recherches sauvegardées avec alertes175 @ViewBuilder176 private var resumeStrip: some View {177 let hasContent = !recents.viewed.isEmpty || !favorites.allItems.isEmpty || !recents.savedSearches.isEmpty178 if hasContent {179 VStack(alignment: .leading, spacing: 10) {180 Text("Reprendre").font(.headline)181 ScrollView(.horizontal, showsIndicators: false) {182 HStack(spacing: 10) {183 ForEach(recents.savedSearches.prefix(3)) { s in184 NavigationLink(value: s.universeID) {185 HStack(spacing: 6) {186 Image(systemName: "bell.badge").font(.caption)187 Text(s.name).font(.caption.weight(.bold)).lineLimit(1)188 if s.newCount > 0 {189 Text("+\(s.newCount)")190 .font(.system(size: 10, design: .monospaced).weight(.bold))191 .padding(.horizontal, 6).padding(.vertical, 2)192 .background(.red, in: Capsule())193 .foregroundStyle(.white)194 }195 }196 .padding(.horizontal, 12).padding(.vertical, 9)197 .kaCard()198 }199 .buttonStyle(KAPressStyle())200 }201 ForEach(recents.viewed.prefix(6)) { item in202 NavigationLink(value: item) {203 HStack(spacing: 8) {204 if let img = item.imageURL {205 KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray)206 .frame(width: 34, height: 34)207 .clipShape(RoundedRectangle(cornerRadius: 7))208 } else {209 Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)210 }211 Text(item.title).font(.caption.weight(.semibold)).lineLimit(1)212 }213 .padding(.horizontal, 10).padding(.vertical, 7)214 .kaCard(accent: Ecosystem.universe(item.universeID)?.accent)215 }216 .buttonStyle(KAPressStyle())217 }218 }219 }220 }221 }222 }223224 /// Collections éditoriales : de vraies requêtes, du vrai contenu225 @ViewBuilder226 private var editorialSection: some View {227 ForEach(Editorial.collections) { c in228 if let items = editorialItems[c.id], !items.isEmpty,229 let u = Ecosystem.universe(c.universeID) {230 VStack(alignment: .leading, spacing: 10) {231 HStack(spacing: 8) {232 Image(systemName: c.symbol).foregroundStyle(u.accent)233 VStack(alignment: .leading, spacing: 0) {234 Text(c.title).font(.headline)235 Text(c.subtitle).font(.caption).foregroundStyle(.secondary)236 }237 Spacer()238 NavigationLink(value: u.id) {239 Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent)240 }241 }242 ScrollView(.horizontal, showsIndicators: false) {243 HStack(spacing: 12) {244 ForEach(items.prefix(8)) { item in245 NavigationLink(value: item) {246 VStack(alignment: .leading, spacing: 0) {247 KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol)248 .frame(width: 170, height: 96).clipped()249 VStack(alignment: .leading, spacing: 2) {250 Text(item.title).font(.caption.weight(.semibold))251 .lineLimit(2, reservesSpace: true)252 if let p = item.priceLabel {253 Text(p).font(.system(.caption2, design: .rounded).weight(.bold))254 .foregroundStyle(u.accent)255 }256 }257 .padding(9)258 }259 .frame(width: 170, alignment: .topLeading)260 .kaCard(accent: u.accent)261 }262 .buttonStyle(KAPressStyle())263 .kaScrollPop()264 }265 }266 .scrollTargetLayout()267 .padding(.vertical, 4)268 }269 .scrollTargetBehavior(.viewAligned)270 }271 }272 }273 }274275 private var disclaimer: some View {276 Text(Ecosystem.disclaimer)277 .font(.caption2).foregroundStyle(.tertiary)278 .padding(.top, 8)279 }280281 // MARK: données282283 /// Univers mis en avant selon l'heure + les préférences d'onboarding.284 private var contextualUniverses: [Universe] {285 let h = Calendar.current.component(.hour, from: Date())286 let weekend = Calendar.current.isDateInWeekend(Date())287 var ids: [String]288 switch h {289 case 5..<11: ids = ["job-ka", "food-ka", "immo-ka"]290 case 11..<14: ids = ["resto-ka", "food-ka", "sorti-ka"]291 case 14..<17: ids = weekend ? ["sorti-ka", "resto-ka", "auto-ka"] : ["immo-ka", "auto-ka", "lou-ka"]292 case 17..<23: ids = ["resto-ka", "sorti-ka", "crea-ka"]293 default: ids = ["sorti-ka", "crea-ka", "lou-ka"]294 }295 let favs = favUniversesRaw.split(separator: ",").map(String.init)296 for f in favs.reversed() where !ids.contains(f) { ids.insert(f, at: 0) }297 return ids.prefix(4).compactMap(Ecosystem.universe).filter { $0.map != nil }298 }299300 private func load() async {301 loading = true302 let universes = contextualUniverses303 var found: [String: [KAItem]] = [:]304 await withTaskGroup(of: (String, [KAItem]).self) { group in305 for u in universes {306 group.addTask { (u.id, (try? await UniverseService.fetch(u, limit: 8)) ?? []) }307 }308 for u in Ecosystem.all where u.statTotalKeys.first != nil {309 _ = u // pouls géré plus bas310 }311 for await (id, items) in group { found[id] = items }312 }313 featured = universes.compactMap { u in314 let items = found[u.id] ?? []315 return items.isEmpty ? nil : (u, items)316 }317 loading = false318 // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil319 var counts: [(Universe, Int)] = []320 await withTaskGroup(of: (String, Int?).self) { group in321 for u in Ecosystem.all {322 group.addTask { (u.id, await UniverseService.liveTotal(u)) }323 }324 for await (id, n) in group {325 if let n, let u = Ecosystem.universe(id) { counts.append((u, n)) }326 }327 }328 pulse = counts.sorted { $0.1 > $1.1 }329 // collections éditoriales (requêtes réelles)330 await withTaskGroup(of: (String, [KAItem]).self) { group in331 for c in Editorial.collections {332 if let u = Ecosystem.universe(c.universeID) {333 group.addTask {334 (c.id, (try? await UniverseService.fetch(u, query: c.query, limit: 8, params: c.params)) ?? [])335 }336 }337 }338 for await (id, items) in group { editorialItems[id] = items }339 }340 }341}342