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