// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // HomeView.swift : accueil — marque, héro éditorial, ticker animé, recherche, // filtres, liste d'annonces // ----------------------------------------------------------------------------- import SwiftUI private let UNIT_TYPES = ["1½", "2½", "3½", "4½", "5½", "Studio", "Loft"] struct HomeView: View { @Environment(AppModel.self) private var model @State private var filters = ListingFilters() @State private var listings: [Listing]? @State private var total = 0 @State private var errorMessage: String? @State private var showFilters = false var body: some View { NavigationStack { ScrollView { LazyVStack(alignment: .leading, spacing: 16, pinnedViews: []) { brandHeader tickerBar searchBar typeChips activePills resultsHeader content } .padding(.horizontal, 16) .padding(.bottom, 24) } .background(LK.paper) .scrollDismissesKeyboard(.immediately) .toolbar(.hidden, for: .navigationBar) .navigationDestination(for: Listing.self) { ListingDetailView(preview: $0) } .sheet(isPresented: $showFilters) { FiltersSheet(filters: $filters) .presentationDetents([.medium, .large]) } .task(id: filters) { await load() } .refreshable { await model.loadGlobals(force: true) await load(debounce: false) } } } // MARK: chargement private func load(debounce: Bool = true) async { errorMessage = nil if debounce { try? await Task.sleep(for: .milliseconds(250)) if Task.isCancelled { return } } do { let r = try await API.listings(filters) listings = r.listings total = r.total } catch is CancellationError { } catch { if (error as? URLError)?.code == .cancelled { return } errorMessage = error.localizedDescription } } // MARK: sections private var brandHeader: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center) { BrandMark(size: 32) Spacer() Text("QC · LÉVIS · MTL · +") .font(LKFont.mono(10, .medium)) .kerning(0.8) .foregroundStyle(LK.ink3) } Text("Tous les logements à louer du Québec. Un seul endroit.") .font(LKFont.display(23, .bold)) .kerning(-0.5) .foregroundStyle(LK.ink) .lineSpacing(1) Kicker(text: "Agrégateur indépendant — \(Fmt.int(model.stats?.sources ?? 190)) gestionnaires") } .padding(.top, 8) } private var tickerBar: some View { Group { if let s = model.stats { TickerBar(text: "\(Fmt.int(s.total)) ANNONCES ◆ \(s.sources) SOURCES ◆ LOYER MOYEN \(Fmt.price(s.avgPrice.map { $0.rounded() })) ◆ MISE À JOUR CONTINUE") } } } private var searchBar: some View { HStack(spacing: 12) { HStack(spacing: 8) { Image(systemName: "magnifyingglass") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(LK.ink2) TextField("Rue, quartier, ville…", text: Binding( get: { filters.q }, set: { filters.q = $0 } )) .autocorrectionDisabled() .font(.system(size: 15)) .foregroundStyle(LK.ink) .tint(LK.green) if !filters.q.isEmpty { Button { filters.q = "" } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(LK.ink3) } } } .padding(.horizontal, 13) .padding(.vertical, 12) .lkCard(radius: 8, offset: 4) Button { showFilters = true } label: { HStack(spacing: 5) { Image(systemName: "slider.horizontal.3") .font(.system(size: 15, weight: .semibold)) if filters.activeCount > 0 { Text("\(filters.activeCount)") .font(LKFont.mono(12, .bold)) } } .padding(.horizontal, 14) .padding(.vertical, 13) .background(filters.activeCount > 0 ? LK.ink : LK.surface) .foregroundStyle(filters.activeCount > 0 ? LK.lime : LK.ink) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(LK.ink, lineWidth: 1.8)) .background(RoundedRectangle(cornerRadius: 8).fill(LK.ink).offset(x: 4, y: 4)) } .buttonStyle(.plain) } .padding(.trailing, 4) } private var typeChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { Chip(label: "Tous", selected: filters.unitType.isEmpty) { filters.unitType = "" } ForEach(UNIT_TYPES, id: \.self) { t in Chip(label: t, selected: filters.unitType == t) { filters.unitType = filters.unitType == t ? "" : t } } Rectangle().fill(LK.line).frame(width: 1.5, height: 22) Chip(label: "⚡ Dispo maintenant", selected: filters.dispoDays == 0) { filters.dispoDays = filters.dispoDays == 0 ? nil : 0 } Chip(label: "🐾 Animaux ok", selected: filters.petsOk) { filters.petsOk.toggle() } Chip(label: "🛋 Meublé", selected: filters.furnished == true) { filters.furnished = filters.furnished == true ? nil : true } } .padding(.vertical, 2) } } /// pastilles « filtres actifs » — un tap retire le filtre @ViewBuilder private var activePills: some View { let pills = pillItems if !pills.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 7) { ForEach(pills, id: \.0) { label, clear in Button { clear() } label: { HStack(spacing: 5) { Text(label) Image(systemName: "xmark") .font(.system(size: 8, weight: .bold)) .opacity(0.6) } .font(LKFont.mono(11, .medium)) .padding(.horizontal, 11) .padding(.vertical, 6) .background(LK.limeSoft) .foregroundStyle(LK.greenDeep) .clipShape(Capsule()) .overlay(Capsule().stroke(LK.green.opacity(0.45), lineWidth: 1.2)) } .buttonStyle(.plain) } } } } } private var pillItems: [(String, () -> Void)] { var out: [(String, () -> Void)] = [] if !filters.q.isEmpty { out.append(("« \(filters.q) »", { filters.q = "" })) } if !filters.city.isEmpty { out.append((filters.city, { filters.city = ""; filters.sector = "" })) } if !filters.sector.isEmpty { out.append((filters.sector, { filters.sector = "" })) } if !filters.unitType.isEmpty { out.append((filters.unitType, { filters.unitType = "" })) } if let p = filters.priceMin { out.append(("≥ \(p) $", { filters.priceMin = nil })) } if let p = filters.priceMax { out.append(("≤ \(p) $", { filters.priceMax = nil })) } if let d = filters.dispoDays { let label = d == 0 ? "Dispo maintenant" : "Dispo d'ici \(d / 30) mois" out.append((label, { filters.dispoDays = nil })) } if filters.petsOk { out.append(("Animaux acceptés", { filters.petsOk = false })) } if let f = filters.furnished { out.append((f ? "Meublé" : "Non meublé", { filters.furnished = nil })) } if let a = filters.areaMin { out.append(("≥ \(a) pi²", { filters.areaMin = nil })) } if !filters.source.isEmpty { out.append((model.sourceName(filters.source), { filters.source = "" })) } return out } private var resultsHeader: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { if listings != nil { Text(Fmt.int(total)) .font(LKFont.display(22, .bold)) .foregroundStyle(LK.ink) Text("logement\(total > 1 ? "s" : "") · triés par prix") .font(LKFont.mono(11, .medium)) .foregroundStyle(LK.ink3) } Spacer() if filters.activeCount > 0 { Button { filters = ListingFilters() } label: { Text("Réinitialiser") .font(LKFont.mono(11, .medium)) .foregroundStyle(LK.danger) .underline() } .buttonStyle(.plain) } } .padding(.top, 4) } @ViewBuilder private var content: some View { if let errorMessage { VStack(spacing: 10) { Image(systemName: "wifi.exclamationmark").font(.system(size: 28)) Text(errorMessage) .font(.system(size: 14)) .multilineTextAlignment(.center) Button("Réessayer") { Task { await load(debounce: false) } } .font(LKFont.display(14, .bold)) .buttonStyle(.borderedProminent) .tint(LK.ink) } .foregroundStyle(LK.ink2) .frame(maxWidth: .infinity) .padding(.vertical, 40) } else if let listings { if listings.isEmpty { VStack(spacing: 8) { Image(systemName: "tray").font(.system(size: 28)) Text("Aucune annonce pour ces filtres.") .font(.system(size: 14)) } .foregroundStyle(LK.ink3) .frame(maxWidth: .infinity) .padding(.vertical, 40) } else { ForEach(listings) { listing in NavigationLink(value: listing) { ListingCardView(listing: listing) } .buttonStyle(.plain) .padding(.trailing, 6) // place pour l'ombre décalée .padding(.bottom, 6) } } } else { ProgressView("Chargement…") .tint(LK.green) .foregroundStyle(LK.ink2) .frame(maxWidth: .infinity) .padding(.vertical, 60) } } } // MARK: - Feuille de filtres struct FiltersSheet: View { @Environment(AppModel.self) private var model @Environment(\.dismiss) private var dismiss @Binding var filters: ListingFilters @State private var sectors: [String] = [] private let priceSteps = [600, 800, 1000, 1200, 1400, 1600, 1800, 2000, 2500, 3000] private let areaSteps = [400, 600, 800, 1000, 1200] private let dispoChoices: [(Int?, String)] = [ (nil, "Peu importe"), (0, "Maintenant"), (30, "1 mois"), (60, "2 mois"), (90, "3 mois"), ] var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 22) { HStack(spacing: 14) { section("Ville") { Picker("Ville", selection: $filters.city) { Text("Toutes").tag("") ForEach(model.facets?.cities ?? [], id: \.self) { Text($0).tag($0) } } .pickerStyle(.menu) .tint(LK.green) } section("Quartier") { Picker("Quartier", selection: $filters.sector) { Text("Tous").tag("") ForEach(sectors, id: \.self) { Text($0).tag($0) } } .pickerStyle(.menu) .tint(LK.green) .disabled(sectors.isEmpty) } Spacer() } section("Loyer mensuel") { HStack(spacing: 10) { rangeMenu(label: filters.priceMin.map { "\($0) $" } ?? "Min", isSet: filters.priceMin != nil) { Button("Min") { filters.priceMin = nil } ForEach(priceSteps.filter { filters.priceMax == nil || $0 < filters.priceMax! }, id: \.self) { p in Button("\(p) $") { filters.priceMin = p } } } Text("—") .font(LKFont.mono(12)) .foregroundStyle(LK.ink3) rangeMenu(label: filters.priceMax.map { "\($0) $" } ?? "Max", isSet: filters.priceMax != nil) { Button("Max") { filters.priceMax = nil } ForEach(priceSteps.filter { filters.priceMin == nil || $0 > filters.priceMin! }, id: \.self) { p in Button("\(p) $") { filters.priceMax = p } } } Spacer() } } section("Disponibilité") { segments(dispoChoices.map(\.1), selectedIndex: dispoChoices.firstIndex(where: { $0.0 == filters.dispoDays }) ?? 0) { i in filters.dispoDays = dispoChoices[i].0 } } section("Meublé") { segments(["Peu importe", "Oui", "Non"], selectedIndex: filters.furnished == nil ? 0 : (filters.furnished! ? 1 : 2)) { i in filters.furnished = i == 0 ? nil : (i == 1) } } section("Animaux") { segments(["Peu importe", "🐾 Acceptés"], selectedIndex: filters.petsOk ? 1 : 0) { i in filters.petsOk = i == 1 } } section("Superficie minimale") { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { Chip(label: "Peu importe", selected: filters.areaMin == nil) { filters.areaMin = nil } ForEach(areaSteps, id: \.self) { a in Chip(label: "\(a) pi² +", selected: filters.areaMin == a) { filters.areaMin = filters.areaMin == a ? nil : a } } } } } section("Gestionnaire") { Picker("Gestionnaire", selection: $filters.source) { Text("Tous").tag("") ForEach(model.facets?.sources ?? [], id: \.source) { s in Text("\(model.sourceName(s.source)) (\(s.n))").tag(s.source) } } .pickerStyle(.menu) .tint(LK.green) } } .padding(20) .padding(.bottom, 10) } .background(LK.paper) .navigationTitle("Filtres") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Réinitialiser") { filters = ListingFilters() } .font(LKFont.mono(12, .medium)) .foregroundStyle(LK.danger) } ToolbarItem(placement: .topBarTrailing) { Button("Terminé") { dismiss() } .font(LKFont.display(15, .bold)) .foregroundStyle(LK.ink) } } // quartiers dépendants de la ville choisie .task(id: filters.city) { let f = try? await API.facets(city: filters.city.isEmpty ? nil : filters.city) sectors = f?.sectors ?? [] if !filters.sector.isEmpty && !sectors.contains(filters.sector) { filters.sector = "" } } } .preferredColorScheme(.light) } // MARK: composants private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { VStack(alignment: .leading, spacing: 10) { Kicker(text: title) content() } .frame(maxWidth: .infinity, alignment: .leading) } /// menu déroulant min/max stylé pilule private func rangeMenu(label: String, isSet: Bool, @ViewBuilder items: () -> some View) -> some View { Menu { items() } label: { HStack(spacing: 6) { Text(label) .font(LKFont.display(14.5, .medium)) Image(systemName: "chevron.down") .font(.system(size: 9, weight: .bold)) } .padding(.horizontal, 14) .padding(.vertical, 10) .background(isSet ? LK.ink : LK.surface) .foregroundStyle(isSet ? LK.lime : LK.ink) .clipShape(Capsule()) .overlay(Capsule().stroke(LK.ink, lineWidth: 1.5)) } } /// segments soudés (équivalent du .seg du site web) private func segments(_ labels: [String], selectedIndex: Int, tap: @escaping (Int) -> Void) -> some View { HStack(spacing: 0) { ForEach(Array(labels.enumerated()), id: \.offset) { i, label in Button { tap(i) } label: { Text(label) .font(LKFont.display(13, .medium)) .padding(.horizontal, 13) .padding(.vertical, 9) .frame(maxWidth: .infinity) .background(i == selectedIndex ? LK.ink : LK.surface) .foregroundStyle(i == selectedIndex ? LK.lime : LK.ink2) } .buttonStyle(.plain) if i < labels.count - 1 { Rectangle().fill(LK.ink).frame(width: 1.5) } } } .clipShape(RoundedRectangle(cornerRadius: 9)) .overlay(RoundedRectangle(cornerRadius: 9).stroke(LK.ink, lineWidth: 1.5)) .fixedSize(horizontal: false, vertical: true) } }