SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
23.0 KB · 503 lines swift
Raw Blame History
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