SPB Git

spb/ka-ios

Public
14.8 KB · 312 lines swift
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
Files · ce73f14a9dd1fe80ae09bf638fcba97f1a74e08a
Menu

Theme