SPB Git

spb/lou-ka-ios Public

Lou·Ka iOS — app SwiftUI native de l'agrégateur de logements du Québec : filtres avancés, carte, stats, et mode Découverte (swipe) avec recommandation on-device

Swift 100%
18.2 KB · 487 lines swift
Raw Blame History
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// DiscoverView.swift : mode Découverte — une annonce plein écran à la fois,5//   swipe à droite = coup de cœur 💚, à gauche = on passe ✕.6//   Chaque geste nourrit le RecoEngine qui reclasse le reste du bassin7//   pour présenter des logements de plus en plus proches de vos goûts.8// -----------------------------------------------------------------------------9import SwiftUI1011struct DiscoverView: View {12    @Environment(AppModel.self) private var model13    @Environment(RecoEngine.self) private var reco1415    @State private var deck: [Listing] = []16    @State private var index = 017    @State private var photoIndex = 018    @State private var drag: CGSize = .zero19    @State private var loading = true20    @State private var showLikes = false21    @State private var detail: Listing?22    @State private var lastDecision: (listing: Listing, liked: Bool)?23    @State private var decisionsSinceRank = 02425    private var current: Listing? { deck.indices.contains(index) ? deck[index] : nil }26    private var next: Listing? { deck.indices.contains(index + 1) ? deck[index + 1] : nil }2728    var body: some View {29        ZStack {30            LK.paper.ignoresSafeArea()31            VStack(spacing: 12) {32                header33                ZStack {34                    if loading {35                        ProgressView("Préparation de votre pile…")36                            .tint(LK.green)37                            .foregroundStyle(LK.ink2)38                            .frame(maxWidth: .infinity, maxHeight: .infinity)39                    } else if let listing = current {40                        if let next {41                            card(next, isTop: false)42                                .scaleEffect(0.94 + min(abs(drag.width) / 1200, 0.06))43                                .offset(y: 12)44                        }45                        card(listing, isTop: true)46                    } else {47                        emptyState48                    }49                }50                .frame(maxHeight: .infinity)51                actionBar52            }53            .padding(.horizontal, 16)54            .padding(.top, 8)55            .padding(.bottom, 10)56        }57        .task { await loadDeck() }58        .sheet(isPresented: $showLikes) { LikesSheet() }59        .sheet(item: $detail) { l in60            NavigationStack {61                ListingDetailView(preview: l)62                    .toolbar {63                        ToolbarItem(placement: .topBarTrailing) {64                            Button("Fermer") { detail = nil }65                        }66                    }67            }68        }69    }7071    // MARK: données7273    private func loadDeck() async {74        guard deck.isEmpty else { return }75        loading = true76        var f = ListingFilters()77        f.city = ""78        let r = try? await API.listings(f, limit: 800)79        let pool = (r?.listings ?? []).filter { !$0.images.isEmpty }80        deck = reco.rank(pool)81        index = 082        loading = false83    }8485    private func decide(liked: Bool) {86        guard let listing = current else { return }87        reco.record(listing, liked: liked)88        lastDecision = (listing, liked)89        decisionsSinceRank += 190        withAnimation(.spring(duration: 0.35)) {91            drag = CGSize(width: liked ? 640 : -640, height: -40)92        }93        DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {94            index += 195            photoIndex = 096            drag = .zero97            // adaptation en continu : reclasser le reste de la pile98            if decisionsSinceRank >= 8 {99                decisionsSinceRank = 0100                let rest = Array(deck.suffix(from: min(index, deck.count)))101                deck = Array(deck.prefix(min(index, deck.count))) + reco.rank(rest)102            }103        }104    }105106    private func undo() {107        guard let last = lastDecision, index > 0 else { return }108        reco.undo(last.listing, wasLiked: last.liked)109        lastDecision = nil110        withAnimation(.spring(duration: 0.3)) {111            index -= 1112            photoIndex = 0113        }114    }115116    // MARK: en-tête117118    private var header: some View {119        HStack(alignment: .center) {120            VStack(alignment: .leading, spacing: 3) {121                Kicker(text: "Découverte")122                let tastes = reco.topTastes()123                Text(tastes.isEmpty124                     ? "Swipez — l'algorithme apprend vos goûts"125                     : "Vos goûts : \(tastes.joined(separator: " · "))")126                    .font(LKFont.mono(10, .medium))127                    .foregroundStyle(LK.ink3)128                    .lineLimit(1)129            }130            Spacer()131            if !loading, current != nil {132                Text("\(min(index + 1, deck.count))/\(deck.count)")133                    .font(LKFont.mono(11, .medium))134                    .foregroundStyle(LK.ink3)135            }136            Button {137                showLikes = true138            } label: {139                HStack(spacing: 5) {140                    Image(systemName: "heart.fill")141                        .font(.system(size: 13))142                    Text("\(reco.likedUIDs.count)")143                        .font(LKFont.mono(12, .bold))144                }145                .padding(.horizontal, 11)146                .padding(.vertical, 7)147                .background(LK.ink)148                .foregroundStyle(LK.lime)149                .clipShape(Capsule())150            }151            .buttonStyle(.plain)152        }153    }154155    // MARK: carte plein écran156157    @ViewBuilder158    private func card(_ listing: Listing, isTop: Bool) -> some View {159        GeometryReader { geo in160            ZStack(alignment: .bottom) {161                photo(listing, size: geo.size, isTop: isTop)162                overlayInfo(listing)163                if isTop { stamps }164            }165            .frame(width: geo.size.width, height: geo.size.height)166            .background(LK.ink)167            .clipShape(RoundedRectangle(cornerRadius: 18))168            .overlay(RoundedRectangle(cornerRadius: 18).stroke(LK.ink, lineWidth: 2))169            .background(170                RoundedRectangle(cornerRadius: 18).fill(LK.ink).offset(x: 6, y: 6)171            )172            .padding(.trailing, 6)173            .padding(.bottom, 6)174            .offset(isTop ? drag : .zero)175            .rotationEffect(isTop ? .degrees(Double(drag.width) / 22) : .zero, anchor: .bottom)176            .gesture(isTop ? dragGesture : nil)177            .onTapGesture { location in178                guard isTop else { return }179                let w = geo.size.width180                if location.y < geo.size.height * 0.62 {181                    if location.x > w * 0.6 {182                        photoIndex = (photoIndex + 1) % max(listing.images.count, 1)183                    } else if location.x < w * 0.4 {184                        photoIndex = (photoIndex - 1 + max(listing.images.count, 1)) % max(listing.images.count, 1)185                    } else {186                        detail = listing187                    }188                } else {189                    detail = listing190                }191            }192        }193    }194195    private var dragGesture: some Gesture {196        DragGesture()197            .onChanged { drag = $0.translation }198            .onEnded { value in199                if value.translation.width > 110 {200                    decide(liked: true)201                } else if value.translation.width < -110 {202                    decide(liked: false)203                } else {204                    withAnimation(.spring(duration: 0.3)) { drag = .zero }205                }206            }207    }208209    private func photo(_ listing: Listing, size: CGSize, isTop: Bool) -> some View {210        let idx = isTop ? min(photoIndex, listing.images.count - 1) : 0211        return ZStack(alignment: .top) {212            AsyncImage(url: URL(string: listing.images[max(0, idx)])) { phase in213                switch phase {214                case .success(let image):215                    image.resizable().aspectRatio(contentMode: .fill)216                case .failure:217                    ZStack {218                        LK.limeSoft219                        Image(systemName: "photo")220                            .font(.system(size: 40))221                            .foregroundStyle(LK.green.opacity(0.5))222                    }223                default:224                    ZStack {225                        LK.surface2226                        ProgressView().tint(LK.green)227                    }228                }229            }230            .frame(width: size.width, height: size.height)231            .clipped()232233            // indicateur de photos façon « stories »234            if isTop, listing.images.count > 1 {235                HStack(spacing: 3) {236                    ForEach(0..<min(listing.images.count, 12), id: \.self) { i in237                        Capsule()238                            .fill(i == idx ? LK.lime : .white.opacity(0.45))239                            .frame(height: 3)240                    }241                }242                .padding(.horizontal, 14)243                .padding(.top, 12)244            }245        }246    }247248    private func overlayInfo(_ listing: Listing) -> some View {249        VStack(alignment: .leading, spacing: 7) {250            HStack(alignment: .firstTextBaseline, spacing: 6) {251                Text(Fmt.price(listing.price, label: listing.priceLabel))252                    .font(LKFont.display(30, .bold))253                    .foregroundStyle(LK.lime)254                if listing.price != nil {255                    Text("/ mois")256                        .font(LKFont.mono(11))257                        .foregroundStyle(.white.opacity(0.75))258                }259                Spacer()260                if !listing.unitType.isEmpty {261                    Text(listing.unitType)262                        .font(LKFont.mono(13, .bold))263                        .padding(.horizontal, 9)264                        .padding(.vertical, 5)265                        .background(LK.lime)266                        .foregroundStyle(LK.ink)267                        .clipShape(RoundedRectangle(cornerRadius: 6))268                }269            }270            Text(listing.title.isEmpty ? listing.address : listing.title)271                .font(LKFont.display(18, .medium))272                .foregroundStyle(.white)273                .lineLimit(2)274            HStack(spacing: 10) {275                label(icon: "mappin", text: [listing.sector, listing.city]276                    .filter { !$0.isEmpty }.joined(separator: " · "))277                if let dispo = Fmt.availability(listing.availabilityDate) {278                    label(icon: "calendar", text: dispo)279                }280                if let area = listing.areaSqft {281                    label(icon: "ruler", text: "\(Int(area)) pi²")282                }283            }284            Text("Toucher pour la fiche complète")285                .font(LKFont.mono(9, .medium))286                .foregroundStyle(.white.opacity(0.55))287                .padding(.top, 2)288        }289        .padding(18)290        .frame(maxWidth: .infinity, alignment: .leading)291        .background(292            LinearGradient(293                colors: [.clear, .black.opacity(0.55), .black.opacity(0.88)],294                startPoint: .top, endPoint: .bottom295            )296        )297    }298299    private func label(icon: String, text: String) -> some View {300        HStack(spacing: 4) {301            Image(systemName: icon).font(.system(size: 10.5))302            Text(text).font(LKFont.mono(10.5, .medium)).lineLimit(1)303        }304        .foregroundStyle(.white.opacity(0.85))305    }306307    /// tampons LIKE / PASSE pendant le glissement308    private var stamps: some View {309        ZStack(alignment: .top) {310            HStack {311                stamp(text: "COUP DE 🧡", color: LK.lime, rotation: -12)312                    .opacity(min(Double(drag.width) / 90, 1))313                Spacer()314                stamp(text: "ON PASSE", color: LK.danger, rotation: 12)315                    .opacity(min(Double(-drag.width) / 90, 1))316            }317            .padding(26)318        }319        .frame(maxHeight: .infinity, alignment: .top)320        .allowsHitTesting(false)321    }322323    private func stamp(text: String, color: Color, rotation: Double) -> some View {324        Text(text)325            .font(LKFont.display(24, .bold))326            .padding(.horizontal, 14)327            .padding(.vertical, 8)328            .foregroundStyle(color)329            .overlay(RoundedRectangle(cornerRadius: 8).stroke(color, lineWidth: 3.5))330            .rotationEffect(.degrees(rotation))331    }332333    // MARK: barre d'actions334335    private var actionBar: some View {336        HStack(spacing: 26) {337            actionButton(icon: "xmark", size: 60, bg: LK.surface, fg: LK.danger) {338                decide(liked: false)339            }340            actionButton(icon: "arrow.uturn.backward", size: 44, bg: LK.surface, fg: LK.ink2) {341                undo()342            }343            .opacity(lastDecision == nil ? 0.35 : 1)344            actionButton(icon: "heart.fill", size: 60, bg: LK.ink, fg: LK.lime) {345                decide(liked: true)346            }347        }348        .frame(maxWidth: .infinity)349        .padding(.top, 2)350        .opacity(current == nil ? 0.3 : 1)351        .disabled(current == nil)352    }353354    private func actionButton(icon: String, size: CGFloat, bg: Color, fg: Color,355                              action: @escaping () -> Void) -> some View {356        Button(action: action) {357            Image(systemName: icon)358                .font(.system(size: size * 0.38, weight: .bold))359                .frame(width: size, height: size)360                .background(bg)361                .foregroundStyle(fg)362                .clipShape(Circle())363                .overlay(Circle().stroke(LK.ink, lineWidth: 1.8))364                .background(Circle().fill(LK.ink).offset(x: 3, y: 3))365        }366        .buttonStyle(.plain)367    }368369    // MARK: fin de pile370371    private var emptyState: some View {372        VStack(spacing: 14) {373            Text("🏁")374                .font(.system(size: 52))375            Text("Vous avez tout vu !")376                .font(LKFont.display(22, .bold))377            Text("\(reco.likedUIDs.count) coups de cœur retenus. Revenez plus tard —\nde nouvelles annonces arrivent chaque heure.")378                .font(.system(size: 14))379                .foregroundStyle(LK.ink2)380                .multilineTextAlignment(.center)381            Button {382                reco.resetProfile()383                deck = []384                Task { await loadDeck() }385            } label: {386                Text("Recommencer à zéro")387                    .font(LKFont.display(15, .bold))388                    .padding(.horizontal, 20)389                    .padding(.vertical, 12)390                    .background(LK.ink)391                    .foregroundStyle(LK.lime)392                    .clipShape(Capsule())393            }394            .buttonStyle(.plain)395            .padding(.top, 6)396        }397        .frame(maxWidth: .infinity, maxHeight: .infinity)398    }399}400401// MARK: - Coups de cœur402403struct LikesSheet: View {404    @Environment(AppModel.self) private var model405    @Environment(RecoEngine.self) private var reco406    @Environment(\.dismiss) private var dismiss407    @State private var likes: [Listing] = []408    @State private var loading = true409    @State private var detail: Listing?410411    var body: some View {412        NavigationStack {413            ScrollView {414                LazyVStack(alignment: .leading, spacing: 14) {415                    if loading {416                        ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)417                    } else if likes.isEmpty {418                        VStack(spacing: 8) {419                            Image(systemName: "heart").font(.system(size: 30))420                            Text("Aucun coup de cœur pour l'instant.\nSwipez à droite dans Découverte !")421                                .multilineTextAlignment(.center)422                                .font(.system(size: 14))423                        }424                        .foregroundStyle(LK.ink3)425                        .frame(maxWidth: .infinity)426                        .padding(.vertical, 50)427                    } else {428                        ForEach(likes) { l in429                            Button {430                                detail = l431                            } label: {432                                ListingCardView(listing: l)433                            }434                            .buttonStyle(.plain)435                            .padding(.trailing, 6)436                            .padding(.bottom, 6)437                            .contextMenu {438                                Button(role: .destructive) {439                                    reco.unlike(l.uid)440                                    likes.removeAll { $0.uid == l.uid }441                                } label: {442                                    Label("Retirer des coups de cœur", systemImage: "heart.slash")443                                }444                            }445                        }446                    }447                }448                .padding(16)449            }450            .background(LK.paper)451            .navigationTitle("Mes coups de cœur")452            .navigationBarTitleDisplayMode(.inline)453            .toolbar {454                ToolbarItem(placement: .topBarTrailing) {455                    Button("Fermer") { dismiss() }456                        .font(LKFont.display(15, .bold))457                }458            }459            .sheet(item: $detail) { l in460                NavigationStack {461                    ListingDetailView(preview: l)462                        .toolbar {463                            ToolbarItem(placement: .topBarTrailing) {464                                Button("Fermer") { detail = nil }465                            }466                        }467                }468            }469            .task { await load() }470        }471        .preferredColorScheme(.light)472    }473474    private func load() async {475        loading = true476        var out: [Listing] = []477        // les fiches aimées, dans l'ordre (récent d'abord) — 30 max affichées478        for uid in reco.likedUIDs.prefix(30) {479            if let l = try? await API.listing(uid: uid) {480                out.append(l)481            }482        }483        likes = out484        loading = false485    }486}487