SPB Git

spb/ka-ios

Public
22.0 KB · 482 lines swift
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
Files · c6336776cac097bea39bb8c05eef6f0e1d49e919
Menu

Theme