SPB Git

spb/ka-ios

Public
15.8 KB · 342 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: 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
Files · d3e6430fa5643e75d57de28dddeb06090050d705
Menu

Theme