// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // HomeView.swift — l'accueil VIVANT : salutation selon l'heure, suggestions // contextuelles (restos le soir, sorties le week-end, emplois le matin…) avec // du VRAI contenu en direct, et le pouls de l'écosystème (compteurs live). 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]] = [:] @Environment(\.colorScheme) private var scheme var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 22) { header pulseStrip resumeStrip ForEach(featured, id: \.universe.id) { section in featuredSection(section.universe, section.items) } editorialSection if loading && featured.isEmpty { ProgressView("KA prépare votre accueil…") .frame(maxWidth: .infinity).padding(40) } disclaimer } .padding(16) } .background(KATheme.paper(scheme)) .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.") } } private var header: some View { VStack(alignment: .leading, spacing: 5) { Text(greeting.title).font(.system(.largeTitle, design: .rounded).weight(.bold)) Text(greeting.sub).font(.subheadline).foregroundStyle(KATheme.ink2(scheme)) } } private var pulseStrip: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(pulse, id: \.0.id) { (u, n) in NavigationLink(value: u.id) { VStack(alignment: .leading, spacing: 3) { Text(n.formatted(.number.locale(Locale(identifier: "fr_CA")))) .font(.system(.headline, design: .rounded).weight(.bold)) .foregroundStyle(u.accent) .contentTransition(.numericText()) Text(u.unit).font(.caption2).foregroundStyle(.secondary) } .padding(.horizontal, 13).padding(.vertical, 9) .kaCard(accent: u.accent) } .buttonStyle(.plain) } 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") } private func featuredSection(_ u: Universe, _ items: [KAItem]) -> some View { VStack(alignment: .leading, spacing: 10) { HStack { 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()) } } .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()) } } .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 selon l'heure + les préférences d'onboarding. 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) } 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 // 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 } } } }