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%
9.6 KB · 271 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// Theme.swift : système de design « éditorial sharp » porté en SwiftUI5//   · palette papier / encre / vert profond / lime électrique (styles.css)6//   · typographie de marque : Space Grotesk (display) + JetBrains Mono (micro)7//   · signature : bordures encre + ombres décalées (néo-brutalisme raffiné)8//   · thème clair UNIQUEMENT (comme le site) — voir .preferredColorScheme9//   · formatage fr-CA (prix, dates, distances)10// -----------------------------------------------------------------------------11import SwiftUI12import UIKit1314extension Color {15    init(hex: UInt32) {16        self.init(17            .sRGB,18            red: Double((hex >> 16) & 0xFF) / 255,19            green: Double((hex >> 8) & 0xFF) / 255,20            blue: Double(hex & 0xFF) / 25521        )22    }23}2425enum LK {26    static let paper = Color(hex: 0xF5F3EE)27    static let surface = Color.white28    static let surface2 = Color(hex: 0xFAF9F5)29    static let ink = Color(hex: 0x141814)30    static let ink2 = Color(hex: 0x4D5551)31    static let ink3 = Color(hex: 0x8B928C)32    static let green = Color(hex: 0x1C5C41)33    static let greenDeep = Color(hex: 0x123F2E)34    static let lime = Color(hex: 0xD9F26B)35    static let limeSoft = Color(hex: 0xF0F9D2)36    static let amber = Color(hex: 0xE8A33D)37    static let amberSoft = Color(hex: 0xFDF3E2)38    static let danger = Color(hex: 0xB3423A)39    static let line = Color(hex: 0x141814).opacity(0.14)40}4142// MARK: - Typographie de marque4344/// Polices embarquées (Fonts/) avec repli système si l'enregistrement échoue.45enum LKFont {46    private static let hasGrotesk = UIFont(name: "SpaceGrotesk-Bold", size: 12) != nil47    private static let hasMono = UIFont(name: "JetBrainsMono-Regular", size: 12) != nil4849    /// Titres & prix — Space Grotesk (géométrique, signature du site)50    static func display(_ size: CGFloat, _ weight: Font.Weight = .bold) -> Font {51        guard hasGrotesk else { return .system(size: size, weight: weight) }52        switch weight {53        case .bold, .heavy, .black: return .custom("SpaceGrotesk-Bold", size: size)54        case .medium, .semibold: return .custom("SpaceGrotesk-Medium", size: size)55        default: return .custom("SpaceGrotesk-Regular", size: size)56        }57    }5859    /// Micro-étiquettes, chiffres, tags — JetBrains Mono60    static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {61        guard hasMono else { return .system(size: size, weight: weight, design: .monospaced) }62        switch weight {63        case .bold, .heavy, .black: return .custom("JetBrainsMono-Bold", size: size)64        case .medium, .semibold: return .custom("JetBrainsMono-Medium", size: size)65        default: return .custom("JetBrainsMono-Regular", size: size)66        }67    }68}6970// MARK: - Carte à ombre décalée (signature visuelle)7172struct LKCardModifier: ViewModifier {73    var radius: CGFloat = 1074    var offset: CGFloat = 575    var borderWidth: CGFloat = 1.87677    func body(content: Content) -> some View {78        content79            .background(LK.surface)80            .clipShape(RoundedRectangle(cornerRadius: radius))81            .overlay(RoundedRectangle(cornerRadius: radius).stroke(LK.ink, lineWidth: borderWidth))82            .background(83                RoundedRectangle(cornerRadius: radius)84                    .fill(LK.ink)85                    .offset(x: offset, y: offset)86            )87    }88}8990extension View {91    func lkCard(radius: CGFloat = 10, offset: CGFloat = 5, borderWidth: CGFloat = 1.8) -> some View {92        modifier(LKCardModifier(radius: radius, offset: offset, borderWidth: borderWidth))93    }94}9596// MARK: - Micro-composants9798/// Étiquette mono majuscule avec tiret vert — le « kicker » du site web99struct Kicker: View {100    let text: String101102    var body: some View {103        HStack(spacing: 8) {104            Rectangle().fill(LK.green).frame(width: 22, height: 2)105            Text(text.uppercased())106                .font(LKFont.mono(11, .medium))107                .kerning(1.4)108                .foregroundStyle(LK.green)109        }110    }111}112113/// Pastille type de logement (encre sur lime) — ex. « 4½ »114struct UnitTypeBadge: View {115    let type: String116117    var body: some View {118        Text(type)119            .font(LKFont.mono(12, .bold))120            .padding(.horizontal, 8)121            .padding(.vertical, 4)122            .background(LK.ink)123            .foregroundStyle(LK.lime)124            .clipShape(RoundedRectangle(cornerRadius: 5))125    }126}127128/// Puce filtre / commodité129struct Chip: View {130    let label: String131    var selected = false132    var action: (() -> Void)?133134    var body: some View {135        let core = Text(label)136            .font(LKFont.display(13.5, .medium))137            .padding(.horizontal, 14)138            .padding(.vertical, 8)139            .background(selected ? LK.ink : LK.surface)140            .foregroundStyle(selected ? LK.lime : LK.ink)141            .clipShape(Capsule())142            .overlay(Capsule().stroke(selected ? LK.ink : LK.ink.opacity(0.35), lineWidth: 1.5))143        if let action {144            Button(action: action) { core }.buttonStyle(.plain)145        } else {146            core147        }148    }149}150151/// Ruban défilant encre/lime — équivalent du « ticker » du site152struct TickerBar: View {153    let text: String154    @State private var segWidth: CGFloat = 0155156    private var segment: some View {157        Text("\(text)  ◆  ")158            .font(LKFont.mono(11, .medium))159            .kerning(1.2)160            .foregroundStyle(LK.lime)161            .fixedSize()162    }163164    var body: some View {165        ZStack(alignment: .leading) {166            // gabarit invisible : mesure la largeur d'un segment167            segment168                .hidden()169                .background(GeometryReader { g in170                    Color.clear.onAppear { segWidth = g.size.width }171                })172            if segWidth > 0 {173                TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { ctx in174                    let t = ctx.date.timeIntervalSinceReferenceDate175                    let phase = CGFloat((t * 28).truncatingRemainder(dividingBy: Double(segWidth)))176                    HStack(spacing: 0) {177                        segment178                        segment179                        segment180                        segment181                    }182                    .offset(x: -phase)183                }184            }185        }186        .frame(maxWidth: .infinity, alignment: .leading)187        .padding(.vertical, 8)188        .background(LK.ink)189        .clipShape(RoundedRectangle(cornerRadius: 7))190        .overlay(RoundedRectangle(cornerRadius: 7).stroke(LK.ink, lineWidth: 1.5))191    }192}193194/// Marque « Lou·Ka » — le « Ka » en encre sur lime, légèrement incliné195struct BrandMark: View {196    var size: CGFloat = 30197198    var body: some View {199        HStack(alignment: .center, spacing: 3) {200            Text("Lou")201                .font(LKFont.display(size, .bold))202                .kerning(-1)203                .foregroundStyle(LK.ink)204            Text("Ka")205                .font(LKFont.display(size * 0.86, .bold))206                .kerning(-1)207                .padding(.horizontal, size * 0.24)208                .padding(.vertical, 2)209                .background(LK.ink)210                .foregroundStyle(LK.lime)211                .clipShape(RoundedRectangle(cornerRadius: 6))212                .rotationEffect(.degrees(-2))213        }214    }215}216217// MARK: - Formatage fr-CA218219enum Fmt {220    static let priceFormatter: NumberFormatter = {221        let f = NumberFormatter()222        f.locale = Locale(identifier: "fr_CA")223        f.numberStyle = .decimal224        f.maximumFractionDigits = 0225        return f226    }()227228    /// 1250 → « 1 250 $ » ; nil → étiquette source ou « Prix sur demande »229    static func price(_ p: Double?, label: String = "") -> String {230        guard let p else { return label.isEmpty ? "Prix sur demande" : label }231        return (priceFormatter.string(from: NSNumber(value: p)) ?? "\(Int(p))") + " $"232    }233234    /// Entier avec séparateur fr-CA (ex. 9 097)235    static func int(_ n: Int) -> String {236        priceFormatter.string(from: NSNumber(value: n)) ?? "\(n)"237    }238239    /// "now" → « Maintenant », "2026-12-01" → « 1ᵉʳ décembre 2026 »240    static func availability(_ iso: String?) -> String? {241        guard let iso, !iso.isEmpty else { return nil }242        if iso == "now" { return "Maintenant" }243        let parts = iso.split(separator: "-").compactMap { Int($0) }244        guard parts.count == 3 else { return nil }245        var comps = DateComponents()246        (comps.year, comps.month, comps.day) = (parts[0], parts[1], parts[2])247        guard let date = Calendar.current.date(from: comps) else { return nil }248        let f = DateFormatter()249        f.locale = Locale(identifier: "fr_CA")250        f.dateFormat = "d MMMM yyyy"251        var txt = f.string(from: date)252        if txt.hasPrefix("1 ") { txt = "1ᵉʳ " + txt.dropFirst(2) }253        return txt254    }255256    /// 250 → « 250 m », 1240 → « 1,2 km »257    static func dist(_ m: Double) -> String {258        if m < 1000 { return "\(Int((m / 10).rounded()) * 10) m" }259        return String(format: "%.1f km", m / 1000).replacingOccurrences(of: ".", with: ",")260    }261262    /// Horodatage Unix → « il y a 2 h »263    static func relative(_ ts: Double?) -> String? {264        guard let ts, ts > 0 else { return nil }265        let f = RelativeDateTimeFormatter()266        f.locale = Locale(identifier: "fr_CA")267        f.unitsStyle = .short268        return f.localizedString(for: Date(timeIntervalSince1970: ts), relativeTo: Date())269    }270}271