1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// HomeView.swift — l'accueil VIVANT : salutation selon l'heure, suggestions3// contextuelles (restos le soir, sorties le week-end, emplois le matin…) avec4// du VRAI contenu en direct, et le pouls de l'écosystème (compteurs live).5import SwiftUI67struct HomeView: View {8 @State private var featured: [(universe: Universe, items: [KAItem])] = []9 @State private var pulse: [(Universe, Int)] = []10 @State private var loading = true11 @AppStorage("ka.favUniverses") private var favUniversesRaw = ""12 @EnvironmentObject private var favorites: FavoritesStore13 @EnvironmentObject private var recents: RecentsStore14 @State private var editorialItems: [String: [KAItem]] = [:]15 @Environment(\.colorScheme) private var scheme1617 var body: some View {18 NavigationStack {19 ScrollView {20 VStack(alignment: .leading, spacing: 22) {21 header22 pulseStrip23 resumeStrip24 ForEach(featured, id: \.universe.id) { section in25 featuredSection(section.universe, section.items)26 }27 editorialSection28 if loading && featured.isEmpty {29 ProgressView("KA prépare votre accueil…")30 .frame(maxWidth: .infinity).padding(40)31 }32 disclaimer33 }34 .padding(16)35 }36 .background(KATheme.paper(scheme))37 .navigationTitle("")38 .toolbar {39 ToolbarItem(placement: .topBarLeading) {40 GroupeKAMark(size: 16)41 }42 }43 .navigationDestination(for: KAItem.self) { ItemDetailView(item: $0) }44 .navigationDestination(for: String.self) { id in45 if let u = Ecosystem.universe(id) { UniverseHomeView(universe: u) }46 }47 .refreshable { await load() }48 .task { await load() }49 }50 }5152 // MARK: sous-vues5354 private var greeting: (title: String, sub: String) {55 let h = Calendar.current.component(.hour, from: Date())56 let weekend = Calendar.current.isDateInWeekend(Date())57 switch h {58 case 5..<12: return ("Bon matin ☀️", weekend ? "On planifie la fin de semaine ?" : "L'écosystème a travaillé toute la nuit.")59 case 12..<17: return ("Bon après-midi 👋", "Tout le Québec, dans votre poche.")60 case 17..<22: return ("Bonsoir 🌆", weekend ? "Resto ? Sortie ? KA a des idées." : "On soupe où ce soir ?")61 default: return ("Bonne nuit 🌙", "Les connecteurs veillent — revenez demain matin.")62 }63 }6465 private var header: some View {66 VStack(alignment: .leading, spacing: 5) {67 Text(greeting.title).font(.system(.largeTitle, design: .rounded).weight(.bold))68 Text(greeting.sub).font(.subheadline).foregroundStyle(KATheme.ink2(scheme))69 }70 }7172 private var pulseStrip: some View {73 ScrollView(.horizontal, showsIndicators: false) {74 HStack(spacing: 10) {75 ForEach(pulse, id: \.0.id) { (u, n) in76 NavigationLink(value: u.id) {77 VStack(alignment: .leading, spacing: 3) {78 Text(n.formatted(.number.locale(Locale(identifier: "fr_CA"))))79 .font(.system(.headline, design: .rounded).weight(.bold))80 .foregroundStyle(u.accent)81 .contentTransition(.numericText())82 Text(u.unit).font(.caption2).foregroundStyle(.secondary)83 }84 .padding(.horizontal, 13).padding(.vertical, 9)85 .kaCard(accent: u.accent)86 }87 .buttonStyle(.plain)88 }89 if pulse.isEmpty {90 Text("Le pouls de l'écosystème…").font(.caption).foregroundStyle(.tertiary).padding(10)91 }92 }93 }94 .accessibilityLabel("Le pouls de l'écosystème en direct")95 }9697 private func featuredSection(_ u: Universe, _ items: [KAItem]) -> some View {98 VStack(alignment: .leading, spacing: 10) {99 HStack {100 KAWordmark(universe: u, size: 17)101 Spacer()102 NavigationLink(value: u.id) {103 Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent)104 }105 }106 ScrollView(.horizontal, showsIndicators: false) {107 HStack(spacing: 12) {108 ForEach(items.prefix(8)) { item in109 NavigationLink(value: item) {110 VStack(alignment: .leading, spacing: 0) {111 KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol)112 .frame(width: 216, height: 118)113 .clipped()114 VStack(alignment: .leading, spacing: 4) {115 Text(item.title).font(.subheadline.weight(.semibold))116 .lineLimit(2, reservesSpace: true)117 .multilineTextAlignment(.leading)118 HStack {119 if let p = item.priceLabel {120 Text(p).font(.system(.caption, design: .rounded).weight(.bold))121 .foregroundStyle(u.accent)122 .lineLimit(1)123 }124 Spacer()125 if let c = item.city {126 Text(c).font(.caption2).foregroundStyle(.tertiary).lineLimit(1)127 }128 }129 }130 .padding(10)131 }132 .frame(width: 216, alignment: .topLeading)133 .kaCard(accent: u.accent)134 }135 .buttonStyle(KAPressStyle())136 }137 }138 .scrollTargetLayout()139 .padding(.vertical, 4)140 }141 .scrollTargetBehavior(.viewAligned)142 }143 }144145 /// Reprendre : derniers consultés + favoris récents + recherches sauvegardées avec alertes146 @ViewBuilder147 private var resumeStrip: some View {148 let hasContent = !recents.viewed.isEmpty || !favorites.allItems.isEmpty || !recents.savedSearches.isEmpty149 if hasContent {150 VStack(alignment: .leading, spacing: 10) {151 Text("Reprendre").font(.headline)152 ScrollView(.horizontal, showsIndicators: false) {153 HStack(spacing: 10) {154 ForEach(recents.savedSearches.prefix(3)) { s in155 NavigationLink(value: s.universeID) {156 HStack(spacing: 6) {157 Image(systemName: "bell.badge").font(.caption)158 Text(s.name).font(.caption.weight(.bold)).lineLimit(1)159 if s.newCount > 0 {160 Text("+\(s.newCount)")161 .font(.system(size: 10, design: .monospaced).weight(.bold))162 .padding(.horizontal, 6).padding(.vertical, 2)163 .background(.red, in: Capsule())164 .foregroundStyle(.white)165 }166 }167 .padding(.horizontal, 12).padding(.vertical, 9)168 .kaCard()169 }170 .buttonStyle(KAPressStyle())171 }172 ForEach(recents.viewed.prefix(6)) { item in173 NavigationLink(value: item) {174 HStack(spacing: 8) {175 if let img = item.imageURL {176 KAImage(url: img, accent: Ecosystem.universe(item.universeID)?.accent ?? .gray)177 .frame(width: 34, height: 34)178 .clipShape(RoundedRectangle(cornerRadius: 7))179 } else {180 Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary)181 }182 Text(item.title).font(.caption.weight(.semibold)).lineLimit(1)183 }184 .padding(.horizontal, 10).padding(.vertical, 7)185 .kaCard(accent: Ecosystem.universe(item.universeID)?.accent)186 }187 .buttonStyle(KAPressStyle())188 }189 }190 }191 }192 }193 }194195 /// Collections éditoriales : de vraies requêtes, du vrai contenu196 @ViewBuilder197 private var editorialSection: some View {198 ForEach(Editorial.collections) { c in199 if let items = editorialItems[c.id], !items.isEmpty,200 let u = Ecosystem.universe(c.universeID) {201 VStack(alignment: .leading, spacing: 10) {202 HStack(spacing: 8) {203 Image(systemName: c.symbol).foregroundStyle(u.accent)204 VStack(alignment: .leading, spacing: 0) {205 Text(c.title).font(.headline)206 Text(c.subtitle).font(.caption).foregroundStyle(.secondary)207 }208 Spacer()209 NavigationLink(value: u.id) {210 Text("Tout voir").font(.caption.weight(.semibold)).foregroundStyle(u.accent)211 }212 }213 ScrollView(.horizontal, showsIndicators: false) {214 HStack(spacing: 12) {215 ForEach(items.prefix(8)) { item in216 NavigationLink(value: item) {217 VStack(alignment: .leading, spacing: 0) {218 KAImage(url: item.imageURL, accent: u.accent, symbol: u.symbol)219 .frame(width: 170, height: 96).clipped()220 VStack(alignment: .leading, spacing: 2) {221 Text(item.title).font(.caption.weight(.semibold))222 .lineLimit(2, reservesSpace: true)223 if let p = item.priceLabel {224 Text(p).font(.system(.caption2, design: .rounded).weight(.bold))225 .foregroundStyle(u.accent)226 }227 }228 .padding(9)229 }230 .frame(width: 170, alignment: .topLeading)231 .kaCard(accent: u.accent)232 }233 .buttonStyle(KAPressStyle())234 }235 }236 .scrollTargetLayout()237 .padding(.vertical, 4)238 }239 .scrollTargetBehavior(.viewAligned)240 }241 }242 }243 }244245 private var disclaimer: some View {246 Text(Ecosystem.disclaimer)247 .font(.caption2).foregroundStyle(.tertiary)248 .padding(.top, 8)249 }250251 // MARK: données252253 /// Univers mis en avant selon l'heure + les préférences d'onboarding.254 private var contextualUniverses: [Universe] {255 let h = Calendar.current.component(.hour, from: Date())256 let weekend = Calendar.current.isDateInWeekend(Date())257 var ids: [String]258 switch h {259 case 5..<11: ids = ["job-ka", "food-ka", "immo-ka"]260 case 11..<14: ids = ["resto-ka", "food-ka", "sorti-ka"]261 case 14..<17: ids = weekend ? ["sorti-ka", "resto-ka", "auto-ka"] : ["immo-ka", "auto-ka", "lou-ka"]262 case 17..<23: ids = ["resto-ka", "sorti-ka", "crea-ka"]263 default: ids = ["sorti-ka", "crea-ka", "lou-ka"]264 }265 let favs = favUniversesRaw.split(separator: ",").map(String.init)266 for f in favs.reversed() where !ids.contains(f) { ids.insert(f, at: 0) }267 return ids.prefix(4).compactMap(Ecosystem.universe).filter { $0.map != nil }268 }269270 private func load() async {271 loading = true272 let universes = contextualUniverses273 var found: [String: [KAItem]] = [:]274 await withTaskGroup(of: (String, [KAItem]).self) { group in275 for u in universes {276 group.addTask { (u.id, (try? await UniverseService.fetch(u, limit: 8)) ?? []) }277 }278 for u in Ecosystem.all where u.statTotalKeys.first != nil {279 _ = u // pouls géré plus bas280 }281 for await (id, items) in group { found[id] = items }282 }283 featured = universes.compactMap { u in284 let items = found[u.id] ?? []285 return items.isEmpty ? nil : (u, items)286 }287 loading = false288 // pouls (compteurs live) — après le contenu pour ne pas retarder l'accueil289 var counts: [(Universe, Int)] = []290 await withTaskGroup(of: (String, Int?).self) { group in291 for u in Ecosystem.all {292 group.addTask { (u.id, await UniverseService.liveTotal(u)) }293 }294 for await (id, n) in group {295 if let n, let u = Ecosystem.universe(id) { counts.append((u, n)) }296 }297 }298 pulse = counts.sorted { $0.1 > $1.1 }299 // collections éditoriales (requêtes réelles)300 await withTaskGroup(of: (String, [KAItem]).self) { group in301 for c in Editorial.collections {302 if let u = Ecosystem.universe(c.universeID) {303 group.addTask {304 (c.id, (try? await UniverseService.fetch(u, query: c.query, limit: 8, params: c.params)) ?? [])305 }306 }307 }308 for await (id, items) in group { editorialItems[id] = items }309 }310 }311}312