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%
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