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%
20.2 KB · 495 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// 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