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// HomeView.swift : accueil — marque, héro éditorial, ticker animé, recherche,5// filtres, liste d'annonces6// -----------------------------------------------------------------------------7import SwiftUI89private let UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Studio", "Loft"]1011struct HomeView: View {12 @Environment(AppModel.self) private var model13 @State private var filters = ListingFilters()14 @State private var listings: [Listing]?15 @State private var total = 016 @State private var errorMessage: String?17 @State private var showFilters = false1819 var body: some View {20 NavigationStack {21 ScrollView {22 LazyVStack(alignment: .leading, spacing: 16, pinnedViews: []) {23 brandHeader24 tickerBar25 searchBar26 typeChips27 activePills28 resultsHeader29 content30 }31 .padding(.horizontal, 16)32 .padding(.bottom, 24)33 }34 .background(LK.paper)35 .scrollDismissesKeyboard(.immediately)36 .toolbar(.hidden, for: .navigationBar)37 .navigationDestination(for: Listing.self) { ListingDetailView(preview: $0) }38 .sheet(isPresented: $showFilters) {39 FiltersSheet(filters: $filters)40 .presentationDetents([.medium, .large])41 }42 .task(id: filters) { await load() }43 .refreshable {44 await model.loadGlobals(force: true)45 await load(debounce: false)46 }47 }48 }4950 // MARK: chargement5152 private func load(debounce: Bool = true) async {53 errorMessage = nil54 if debounce {55 try? await Task.sleep(for: .milliseconds(250))56 if Task.isCancelled { return }57 }58 do {59 let r = try await API.listings(filters)60 listings = r.listings61 total = r.total62 } catch is CancellationError {63 } catch {64 if (error as? URLError)?.code == .cancelled { return }65 errorMessage = error.localizedDescription66 }67 }6869 // MARK: sections7071 private var brandHeader: some View {72 VStack(alignment: .leading, spacing: 12) {73 HStack(alignment: .center) {74 BrandMark(size: 32)75 Spacer()76 Text("QC · LÉVIS · MTL · +")77 .font(LKFont.mono(10, .medium))78 .kerning(0.8)79 .foregroundStyle(LK.ink3)80 }81 Text("Tous les logements à louer du Québec. Un seul endroit.")82 .font(LKFont.display(23, .bold))83 .kerning(-0.5)84 .foregroundStyle(LK.ink)85 .lineSpacing(1)86 Kicker(text: "Agrégateur indépendant — \(Fmt.int(model.stats?.sources ?? 190)) gestionnaires")87 }88 .padding(.top, 8)89 }9091 private var tickerBar: some View {92 Group {93 if let s = model.stats {94 TickerBar(text: "\(Fmt.int(s.total)) ANNONCES ◆ \(s.sources) SOURCES ◆ LOYER MOYEN \(Fmt.price(s.avgPrice.map { $0.rounded() })) ◆ MISE À JOUR CONTINUE")95 }96 }97 }9899 private var searchBar: some View {100 HStack(spacing: 12) {101 HStack(spacing: 8) {102 Image(systemName: "magnifyingglass")103 .font(.system(size: 15, weight: .semibold))104 .foregroundStyle(LK.ink2)105 TextField("Rue, quartier, ville…", text: Binding(106 get: { filters.q },107 set: { filters.q = $0 }108 ))109 .autocorrectionDisabled()110 .font(.system(size: 15))111 .foregroundStyle(LK.ink)112 .tint(LK.green)113 if !filters.q.isEmpty {114 Button {115 filters.q = ""116 } label: {117 Image(systemName: "xmark.circle.fill").foregroundStyle(LK.ink3)118 }119 }120 }121 .padding(.horizontal, 13)122 .padding(.vertical, 12)123 .lkCard(radius: 8, offset: 4)124125 Button {126 showFilters = true127 } label: {128 HStack(spacing: 5) {129 Image(systemName: "slider.horizontal.3")130 .font(.system(size: 15, weight: .semibold))131 if filters.activeCount > 0 {132 Text("\(filters.activeCount)")133 .font(LKFont.mono(12, .bold))134 }135 }136 .padding(.horizontal, 14)137 .padding(.vertical, 13)138 .background(filters.activeCount > 0 ? LK.ink : LK.surface)139 .foregroundStyle(filters.activeCount > 0 ? LK.lime : LK.ink)140 .clipShape(RoundedRectangle(cornerRadius: 8))141 .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.ink, lineWidth: 1.8))142 .background(RoundedRectangle(cornerRadius: 8).fill(LK.ink).offset(x: 4, y: 4))143 }144 .buttonStyle(.plain)145 }146 .padding(.trailing, 4)147 }148149 private var typeChips: some View {150 ScrollView(.horizontal, showsIndicators: false) {151 HStack(spacing: 8) {152 Chip(label: "Tous", selected: filters.unitType.isEmpty) {153 filters.unitType = ""154 }155 ForEach(UNIT_TYPES, id: \.self) { t in156 Chip(label: t, selected: filters.unitType == t) {157 filters.unitType = filters.unitType == t ? "" : t158 }159 }160 Rectangle().fill(LK.line).frame(width: 1.5, height: 22)161 Chip(label: "⚡ Dispo maintenant", selected: filters.dispoDays == 0) {162 filters.dispoDays = filters.dispoDays == 0 ? nil : 0163 }164 Chip(label: "🐾 Animaux ok", selected: filters.petsOk) {165 filters.petsOk.toggle()166 }167 Chip(label: "🛋 Meublé", selected: filters.furnished == true) {168 filters.furnished = filters.furnished == true ? nil : true169 }170 }171 .padding(.vertical, 2)172 }173 }174175 /// pastilles « filtres actifs » — un tap retire le filtre176 @ViewBuilder177 private var activePills: some View {178 let pills = pillItems179 if !pills.isEmpty {180 ScrollView(.horizontal, showsIndicators: false) {181 HStack(spacing: 7) {182 ForEach(pills, id: \.0) { label, clear in183 Button {184 clear()185 } label: {186 HStack(spacing: 5) {187 Text(label)188 Image(systemName: "xmark")189 .font(.system(size: 8, weight: .bold))190 .opacity(0.6)191 }192 .font(LKFont.mono(11, .medium))193 .padding(.horizontal, 11)194 .padding(.vertical, 6)195 .background(LK.limeSoft)196 .foregroundStyle(LK.greenDeep)197 .clipShape(Capsule())198 .overlay(Capsule().stroke(LK.green.opacity(0.45), lineWidth: 1.2))199 }200 .buttonStyle(.plain)201 }202 }203 }204 }205 }206207 private var pillItems: [(String, () -> Void)] {208 var out: [(String, () -> Void)] = []209 if !filters.q.isEmpty { out.append(("« \(filters.q) »", { filters.q = "" })) }210 if !filters.city.isEmpty { out.append((filters.city, { filters.city = ""; filters.sector = "" })) }211 if !filters.sector.isEmpty { out.append((filters.sector, { filters.sector = "" })) }212 if !filters.unitType.isEmpty { out.append((filters.unitType, { filters.unitType = "" })) }213 if let p = filters.priceMin { out.append(("≥ \(p) $", { filters.priceMin = nil })) }214 if let p = filters.priceMax { out.append(("≤ \(p) $", { filters.priceMax = nil })) }215 if let d = filters.dispoDays {216 let label = d == 0 ? "Dispo maintenant" : "Dispo d'ici \(d / 30) mois"217 out.append((label, { filters.dispoDays = nil }))218 }219 if filters.petsOk { out.append(("Animaux acceptés", { filters.petsOk = false })) }220 if let f = filters.furnished { out.append((f ? "Meublé" : "Non meublé", { filters.furnished = nil })) }221 if let a = filters.areaMin { out.append(("≥ \(a) pi²", { filters.areaMin = nil })) }222 if !filters.source.isEmpty { out.append((model.sourceName(filters.source), { filters.source = "" })) }223 return out224 }225226 private var resultsHeader: some View {227 HStack(alignment: .firstTextBaseline, spacing: 8) {228 if listings != nil {229 Text(Fmt.int(total))230 .font(LKFont.display(22, .bold))231 .foregroundStyle(LK.ink)232 Text("logement\(total > 1 ? "s" : "") · triés par prix")233 .font(LKFont.mono(11, .medium))234 .foregroundStyle(LK.ink3)235 }236 Spacer()237 if filters.activeCount > 0 {238 Button {239 filters = ListingFilters()240 } label: {241 Text("Réinitialiser")242 .font(LKFont.mono(11, .medium))243 .foregroundStyle(LK.danger)244 .underline()245 }246 .buttonStyle(.plain)247 }248 }249 .padding(.top, 4)250 }251252 @ViewBuilder253 private var content: some View {254 if let errorMessage {255 VStack(spacing: 10) {256 Image(systemName: "wifi.exclamationmark").font(.system(size: 28))257 Text(errorMessage)258 .font(.system(size: 14))259 .multilineTextAlignment(.center)260 Button("Réessayer") { Task { await load(debounce: false) } }261 .font(LKFont.display(14, .bold))262 .buttonStyle(.borderedProminent)263 .tint(LK.ink)264 }265 .foregroundStyle(LK.ink2)266 .frame(maxWidth: .infinity)267 .padding(.vertical, 40)268 } else if let listings {269 if listings.isEmpty {270 VStack(spacing: 8) {271 Image(systemName: "tray").font(.system(size: 28))272 Text("Aucune annonce pour ces filtres.")273 .font(.system(size: 14))274 }275 .foregroundStyle(LK.ink3)276 .frame(maxWidth: .infinity)277 .padding(.vertical, 40)278 } else {279 ForEach(listings) { listing in280 NavigationLink(value: listing) {281 ListingCardView(listing: listing)282 }283 .buttonStyle(.plain)284 .padding(.trailing, 6) // place pour l'ombre décalée285 .padding(.bottom, 6)286 }287 }288 } else {289 ProgressView("Chargement…")290 .tint(LK.green)291 .foregroundStyle(LK.ink2)292 .frame(maxWidth: .infinity)293 .padding(.vertical, 60)294 }295 }296}297298// MARK: - Feuille de filtres299300struct FiltersSheet: View {301 @Environment(AppModel.self) private var model302 @Environment(\.dismiss) private var dismiss303 @Binding var filters: ListingFilters304 @State private var sectors: [String] = []305306 private let priceSteps = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000]307 private let areaSteps = [400, 600, 800, 1000, 1200]308 private let dispoChoices: [(Int?, String)] = [309 (nil, "Peu importe"), (0, "Maintenant"), (30, "1 mois"), (60, "2 mois"), (90, "3 mois"),310 ]311312 var body: some View {313 NavigationStack {314 ScrollView {315 VStack(alignment: .leading, spacing: 22) {316 HStack(spacing: 14) {317 section("Ville") {318 Picker("Ville", selection: $filters.city) {319 Text("Toutes").tag("")320 ForEach(model.facets?.cities ?? [], id: \.self) {321 Text($0).tag($0)322 }323 }324 .pickerStyle(.menu)325 .tint(LK.green)326 }327 section("Quartier") {328 Picker("Quartier", selection: $filters.sector) {329 Text("Tous").tag("")330 ForEach(sectors, id: \.self) {331 Text($0).tag($0)332 }333 }334 .pickerStyle(.menu)335 .tint(LK.green)336 .disabled(sectors.isEmpty)337 }338 Spacer()339 }340341 section("Loyer mensuel") {342 HStack(spacing: 10) {343 rangeMenu(label: filters.priceMin.map { "\($0) $" } ?? "Min",344 isSet: filters.priceMin != nil) {345 Button("Min") { filters.priceMin = nil }346 ForEach(priceSteps.filter { filters.priceMax == nil || $0 < filters.priceMax! }, id: \.self) { p in347 Button("\(p) $") { filters.priceMin = p }348 }349 }350 Text("—")351 .font(LKFont.mono(12))352 .foregroundStyle(LK.ink3)353 rangeMenu(label: filters.priceMax.map { "\($0) $" } ?? "Max",354 isSet: filters.priceMax != nil) {355 Button("Max") { filters.priceMax = nil }356 ForEach(priceSteps.filter { filters.priceMin == nil || $0 > filters.priceMin! }, id: \.self) { p in357 Button("\(p) $") { filters.priceMax = p }358 }359 }360 Spacer()361 }362 }363364 section("Disponibilité") {365 segments(dispoChoices.map(\.1), selectedIndex: dispoChoices.firstIndex(where: { $0.0 == filters.dispoDays }) ?? 0) { i in366 filters.dispoDays = dispoChoices[i].0367 }368 }369370 section("Meublé") {371 segments(["Peu importe", "Oui", "Non"],372 selectedIndex: filters.furnished == nil ? 0 : (filters.furnished! ? 1 : 2)) { i in373 filters.furnished = i == 0 ? nil : (i == 1)374 }375 }376377 section("Animaux") {378 segments(["Peu importe", "🐾 Acceptés"], selectedIndex: filters.petsOk ? 1 : 0) { i in379 filters.petsOk = i == 1380 }381 }382383 section("Superficie minimale") {384 ScrollView(.horizontal, showsIndicators: false) {385 HStack(spacing: 8) {386 Chip(label: "Peu importe", selected: filters.areaMin == nil) {387 filters.areaMin = nil388 }389 ForEach(areaSteps, id: \.self) { a in390 Chip(label: "\(a) pi² +", selected: filters.areaMin == a) {391 filters.areaMin = filters.areaMin == a ? nil : a392 }393 }394 }395 }396 }397398 section("Gestionnaire") {399 Picker("Gestionnaire", selection: $filters.source) {400 Text("Tous").tag("")401 ForEach(model.facets?.sources ?? [], id: \.source) { s in402 Text("\(model.sourceName(s.source)) (\(s.n))").tag(s.source)403 }404 }405 .pickerStyle(.menu)406 .tint(LK.green)407 }408 }409 .padding(20)410 .padding(.bottom, 10)411 }412 .background(LK.paper)413 .navigationTitle("Filtres")414 .navigationBarTitleDisplayMode(.inline)415 .toolbar {416 ToolbarItem(placement: .topBarLeading) {417 Button("Réinitialiser") { filters = ListingFilters() }418 .font(LKFont.mono(12, .medium))419 .foregroundStyle(LK.danger)420 }421 ToolbarItem(placement: .topBarTrailing) {422 Button("Terminé") { dismiss() }423 .font(LKFont.display(15, .bold))424 .foregroundStyle(LK.ink)425 }426 }427 // quartiers dépendants de la ville choisie428 .task(id: filters.city) {429 let f = try? await API.facets(city: filters.city.isEmpty ? nil : filters.city)430 sectors = f?.sectors ?? []431 if !filters.sector.isEmpty && !sectors.contains(filters.sector) {432 filters.sector = ""433 }434 }435 }436 .preferredColorScheme(.light)437 }438439 // MARK: composants440441 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {442 VStack(alignment: .leading, spacing: 10) {443 Kicker(text: title)444 content()445 }446 .frame(maxWidth: .infinity, alignment: .leading)447 }448449 /// menu déroulant min/max stylé pilule450 private func rangeMenu(label: String, isSet: Bool, @ViewBuilder items: () -> some View) -> some View {451 Menu {452 items()453 } label: {454 HStack(spacing: 6) {455 Text(label)456 .font(LKFont.display(14.5, .medium))457 Image(systemName: "chevron.down")458 .font(.system(size: 9, weight: .bold))459 }460 .padding(.horizontal, 14)461 .padding(.vertical, 10)462 .background(isSet ? LK.ink : LK.surface)463 .foregroundStyle(isSet ? LK.lime : LK.ink)464 .clipShape(Capsule())465 .overlay(Capsule().stroke(LK.ink, lineWidth: 1.5))466 }467 }468469 /// segments soudés (équivalent du .seg du site web)470 private func segments(_ labels: [String], selectedIndex: Int, tap: @escaping (Int) -> Void) -> some View {471 HStack(spacing: 0) {472 ForEach(Array(labels.enumerated()), id: \.offset) { i, label in473 Button {474 tap(i)475 } label: {476 Text(label)477 .font(LKFont.display(13, .medium))478 .padding(.horizontal, 13)479 .padding(.vertical, 9)480 .frame(maxWidth: .infinity)481 .background(i == selectedIndex ? LK.ink : LK.surface)482 .foregroundStyle(i == selectedIndex ? LK.lime : LK.ink2)483 }484 .buttonStyle(.plain)485 if i < labels.count - 1 {486 Rectangle().fill(LK.ink).frame(width: 1.5)487 }488 }489 }490 .clipShape(RoundedRectangle(cornerRadius: 9))491 .overlay(RoundedRectangle(cornerRadius: 9).stroke(LK.ink, lineWidth: 1.5))492 .fixedSize(horizontal: false, vertical: true)493 }494}495