// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // DiscoverView.swift : mode Découverte — une annonce plein écran à la fois, // swipe à droite = coup de cœur 💚, à gauche = on passe ✕. // Chaque geste nourrit le RecoEngine qui reclasse le reste du bassin // pour présenter des logements de plus en plus proches de vos goûts. // ----------------------------------------------------------------------------- import SwiftUI struct DiscoverView: View { @Environment(AppModel.self) private var model @Environment(RecoEngine.self) private var reco @State private var deck: [Listing] = [] @State private var index = 0 @State private var photoIndex = 0 @State private var drag: CGSize = .zero @State private var loading = true @State private var showLikes = false @State private var detail: Listing? @State private var lastDecision: (listing: Listing, liked: Bool)? @State private var decisionsSinceRank = 0 private var current: Listing? { deck.indices.contains(index) ? deck[index] : nil } private var next: Listing? { deck.indices.contains(index + 1) ? deck[index + 1] : nil } var body: some View { ZStack { LK.paper.ignoresSafeArea() VStack(spacing: 12) { header ZStack { if loading { ProgressView("Préparation de votre pile…") .tint(LK.green) .foregroundStyle(LK.ink2) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let listing = current { if let next { card(next, isTop: false) .scaleEffect(0.94 + min(abs(drag.width) / 1200, 0.06)) .offset(y: 12) } card(listing, isTop: true) } else { emptyState } } .frame(maxHeight: .infinity) actionBar } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 10) } .task { await loadDeck() } .sheet(isPresented: $showLikes) { LikesSheet() } .sheet(item: $detail) { l in NavigationStack { ListingDetailView(preview: l) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Fermer") { detail = nil } } } } } } // MARK: données private func loadDeck() async { guard deck.isEmpty else { return } loading = true var f = ListingFilters() f.city = "" let r = try? await API.listings(f, limit: 800) let pool = (r?.listings ?? []).filter { !$0.images.isEmpty } deck = reco.rank(pool) index = 0 loading = false } private func decide(liked: Bool) { guard let listing = current else { return } reco.record(listing, liked: liked) lastDecision = (listing, liked) decisionsSinceRank += 1 withAnimation(.spring(duration: 0.35)) { drag = CGSize(width: liked ? 640 : -640, height: -40) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { index += 1 photoIndex = 0 drag = .zero // adaptation en continu : reclasser le reste de la pile if decisionsSinceRank >= 8 { decisionsSinceRank = 0 let rest = Array(deck.suffix(from: min(index, deck.count))) deck = Array(deck.prefix(min(index, deck.count))) + reco.rank(rest) } } } private func undo() { guard let last = lastDecision, index > 0 else { return } reco.undo(last.listing, wasLiked: last.liked) lastDecision = nil withAnimation(.spring(duration: 0.3)) { index -= 1 photoIndex = 0 } } // MARK: en-tête private var header: some View { HStack(alignment: .center) { VStack(alignment: .leading, spacing: 3) { Kicker(text: "Découverte") let tastes = reco.topTastes() Text(tastes.isEmpty ? "Swipez — l'algorithme apprend vos goûts" : "Vos goûts : \(tastes.joined(separator: " · "))") .font(LKFont.mono(10, .medium)) .foregroundStyle(LK.ink3) .lineLimit(1) } Spacer() if !loading, current != nil { Text("\(min(index + 1, deck.count))/\(deck.count)") .font(LKFont.mono(11, .medium)) .foregroundStyle(LK.ink3) } Button { showLikes = true } label: { HStack(spacing: 5) { Image(systemName: "heart.fill") .font(.system(size: 13)) Text("\(reco.likedUIDs.count)") .font(LKFont.mono(12, .bold)) } .padding(.horizontal, 11) .padding(.vertical, 7) .background(LK.ink) .foregroundStyle(LK.lime) .clipShape(Capsule()) } .buttonStyle(.plain) } } // MARK: carte plein écran @ViewBuilder private func card(_ listing: Listing, isTop: Bool) -> some View { GeometryReader { geo in ZStack(alignment: .bottom) { photo(listing, size: geo.size, isTop: isTop) overlayInfo(listing) if isTop { stamps } } .frame(width: geo.size.width, height: geo.size.height) .background(LK.ink) .clipShape(RoundedRectangle(cornerRadius: 18)) .overlay(RoundedRectangle(cornerRadius: 18).stroke(LK.ink, lineWidth: 2)) .background( RoundedRectangle(cornerRadius: 18).fill(LK.ink).offset(x: 6, y: 6) ) .padding(.trailing, 6) .padding(.bottom, 6) .offset(isTop ? drag : .zero) .rotationEffect(isTop ? .degrees(Double(drag.width) / 22) : .zero, anchor: .bottom) .gesture(isTop ? dragGesture : nil) .onTapGesture { location in guard isTop else { return } let w = geo.size.width if location.y < geo.size.height * 0.62 { if location.x > w * 0.6 { photoIndex = (photoIndex + 1) % max(listing.images.count, 1) } else if location.x < w * 0.4 { photoIndex = (photoIndex - 1 + max(listing.images.count, 1)) % max(listing.images.count, 1) } else { detail = listing } } else { detail = listing } } } } private var dragGesture: some Gesture { DragGesture() .onChanged { drag = $0.translation } .onEnded { value in if value.translation.width > 110 { decide(liked: true) } else if value.translation.width < -110 { decide(liked: false) } else { withAnimation(.spring(duration: 0.3)) { drag = .zero } } } } private func photo(_ listing: Listing, size: CGSize, isTop: Bool) -> some View { let idx = isTop ? min(photoIndex, listing.images.count - 1) : 0 return ZStack(alignment: .top) { AsyncImage(url: URL(string: listing.images[max(0, idx)])) { phase in switch phase { case .success(let image): image.resizable().aspectRatio(contentMode: .fill) case .failure: ZStack { LK.limeSoft Image(systemName: "photo") .font(.system(size: 40)) .foregroundStyle(LK.green.opacity(0.5)) } default: ZStack { LK.surface2 ProgressView().tint(LK.green) } } } .frame(width: size.width, height: size.height) .clipped() // indicateur de photos façon « stories » if isTop, listing.images.count > 1 { HStack(spacing: 3) { ForEach(0.. some View { VStack(alignment: .leading, spacing: 7) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(Fmt.price(listing.price, label: listing.priceLabel)) .font(LKFont.display(30, .bold)) .foregroundStyle(LK.lime) if listing.price != nil { Text("/ mois") .font(LKFont.mono(11)) .foregroundStyle(.white.opacity(0.75)) } Spacer() if !listing.unitType.isEmpty { Text(listing.unitType) .font(LKFont.mono(13, .bold)) .padding(.horizontal, 9) .padding(.vertical, 5) .background(LK.lime) .foregroundStyle(LK.ink) .clipShape(RoundedRectangle(cornerRadius: 6)) } } Text(listing.title.isEmpty ? listing.address : listing.title) .font(LKFont.display(18, .medium)) .foregroundStyle(.white) .lineLimit(2) HStack(spacing: 10) { label(icon: "mappin", text: [listing.sector, listing.city] .filter { !$0.isEmpty }.joined(separator: " · ")) if let dispo = Fmt.availability(listing.availabilityDate) { label(icon: "calendar", text: dispo) } if let area = listing.areaSqft { label(icon: "ruler", text: "\(Int(area)) pi²") } } Text("Toucher pour la fiche complète") .font(LKFont.mono(9, .medium)) .foregroundStyle(.white.opacity(0.55)) .padding(.top, 2) } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) .background( LinearGradient( colors: [.clear, .black.opacity(0.55), .black.opacity(0.88)], startPoint: .top, endPoint: .bottom ) ) } private func label(icon: String, text: String) -> some View { HStack(spacing: 4) { Image(systemName: icon).font(.system(size: 10.5)) Text(text).font(LKFont.mono(10.5, .medium)).lineLimit(1) } .foregroundStyle(.white.opacity(0.85)) } /// tampons LIKE / PASSE pendant le glissement private var stamps: some View { ZStack(alignment: .top) { HStack { stamp(text: "COUP DE 🧡", color: LK.lime, rotation: -12) .opacity(min(Double(drag.width) / 90, 1)) Spacer() stamp(text: "ON PASSE", color: LK.danger, rotation: 12) .opacity(min(Double(-drag.width) / 90, 1)) } .padding(26) } .frame(maxHeight: .infinity, alignment: .top) .allowsHitTesting(false) } private func stamp(text: String, color: Color, rotation: Double) -> some View { Text(text) .font(LKFont.display(24, .bold)) .padding(.horizontal, 14) .padding(.vertical, 8) .foregroundStyle(color) .overlay(RoundedRectangle(cornerRadius: 8).stroke(color, lineWidth: 3.5)) .rotationEffect(.degrees(rotation)) } // MARK: barre d'actions private var actionBar: some View { HStack(spacing: 26) { actionButton(icon: "xmark", size: 60, bg: LK.surface, fg: LK.danger) { decide(liked: false) } actionButton(icon: "arrow.uturn.backward", size: 44, bg: LK.surface, fg: LK.ink2) { undo() } .opacity(lastDecision == nil ? 0.35 : 1) actionButton(icon: "heart.fill", size: 60, bg: LK.ink, fg: LK.lime) { decide(liked: true) } } .frame(maxWidth: .infinity) .padding(.top, 2) .opacity(current == nil ? 0.3 : 1) .disabled(current == nil) } private func actionButton(icon: String, size: CGFloat, bg: Color, fg: Color, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: icon) .font(.system(size: size * 0.38, weight: .bold)) .frame(width: size, height: size) .background(bg) .foregroundStyle(fg) .clipShape(Circle()) .overlay(Circle().stroke(LK.ink, lineWidth: 1.8)) .background(Circle().fill(LK.ink).offset(x: 3, y: 3)) } .buttonStyle(.plain) } // MARK: fin de pile private var emptyState: some View { VStack(spacing: 14) { Text("🏁") .font(.system(size: 52)) Text("Vous avez tout vu !") .font(LKFont.display(22, .bold)) Text("\(reco.likedUIDs.count) coups de cœur retenus. Revenez plus tard —\nde nouvelles annonces arrivent chaque heure.") .font(.system(size: 14)) .foregroundStyle(LK.ink2) .multilineTextAlignment(.center) Button { reco.resetProfile() deck = [] Task { await loadDeck() } } label: { Text("Recommencer à zéro") .font(LKFont.display(15, .bold)) .padding(.horizontal, 20) .padding(.vertical, 12) .background(LK.ink) .foregroundStyle(LK.lime) .clipShape(Capsule()) } .buttonStyle(.plain) .padding(.top, 6) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - Coups de cœur struct LikesSheet: View { @Environment(AppModel.self) private var model @Environment(RecoEngine.self) private var reco @Environment(\.dismiss) private var dismiss @State private var likes: [Listing] = [] @State private var loading = true @State private var detail: Listing? var body: some View { NavigationStack { ScrollView { LazyVStack(alignment: .leading, spacing: 14) { if loading { ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40) } else if likes.isEmpty { VStack(spacing: 8) { Image(systemName: "heart").font(.system(size: 30)) Text("Aucun coup de cœur pour l'instant.\nSwipez à droite dans Découverte !") .multilineTextAlignment(.center) .font(.system(size: 14)) } .foregroundStyle(LK.ink3) .frame(maxWidth: .infinity) .padding(.vertical, 50) } else { ForEach(likes) { l in Button { detail = l } label: { ListingCardView(listing: l) } .buttonStyle(.plain) .padding(.trailing, 6) .padding(.bottom, 6) .contextMenu { Button(role: .destructive) { reco.unlike(l.uid) likes.removeAll { $0.uid == l.uid } } label: { Label("Retirer des coups de cœur", systemImage: "heart.slash") } } } } } .padding(16) } .background(LK.paper) .navigationTitle("Mes coups de cœur") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Fermer") { dismiss() } .font(LKFont.display(15, .bold)) } } .sheet(item: $detail) { l in NavigationStack { ListingDetailView(preview: l) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Fermer") { detail = nil } } } } } .task { await load() } } .preferredColorScheme(.light) } private func load() async { loading = true var out: [Listing] = [] // les fiches aimées, dans l'ordre (récent d'abord) — 30 max affichées for uid in reco.likedUIDs.prefix(30) { if let l = try? await API.listing(uid: uid) { out.append(l) } } likes = out loading = false } }