// ----------------------------------------------------------------------------- // Lou-Ka — Agrégateur de logements à louer (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Theme.swift : système de design « éditorial sharp » porté en SwiftUI // · palette papier / encre / vert profond / lime électrique (styles.css) // · typographie de marque : Space Grotesk (display) + JetBrains Mono (micro) // · signature : bordures encre + ombres décalées (néo-brutalisme raffiné) // · thème clair UNIQUEMENT (comme le site) — voir .preferredColorScheme // · formatage fr-CA (prix, dates, distances) // ----------------------------------------------------------------------------- import SwiftUI import UIKit extension Color { init(hex: UInt32) { self.init( .sRGB, red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255 ) } } enum LK { static let paper = Color(hex: 0xF5F3EE) static let surface = Color.white static let surface2 = Color(hex: 0xFAF9F5) static let ink = Color(hex: 0x141814) static let ink2 = Color(hex: 0x4D5551) static let ink3 = Color(hex: 0x8B928C) static let green = Color(hex: 0x1C5C41) static let greenDeep = Color(hex: 0x123F2E) static let lime = Color(hex: 0xD9F26B) static let limeSoft = Color(hex: 0xF0F9D2) static let amber = Color(hex: 0xE8A33D) static let amberSoft = Color(hex: 0xFDF3E2) static let danger = Color(hex: 0xB3423A) static let line = Color(hex: 0x141814).opacity(0.14) } // MARK: - Typographie de marque /// Polices embarquées (Fonts/) avec repli système si l'enregistrement échoue. enum LKFont { private static let hasGrotesk = UIFont(name: "SpaceGrotesk-Bold", size: 12) != nil private static let hasMono = UIFont(name: "JetBrainsMono-Regular", size: 12) != nil /// Titres & prix — Space Grotesk (géométrique, signature du site) static func display(_ size: CGFloat, _ weight: Font.Weight = .bold) -> Font { guard hasGrotesk else { return .system(size: size, weight: weight) } switch weight { case .bold, .heavy, .black: return .custom("SpaceGrotesk-Bold", size: size) case .medium, .semibold: return .custom("SpaceGrotesk-Medium", size: size) default: return .custom("SpaceGrotesk-Regular", size: size) } } /// Micro-étiquettes, chiffres, tags — JetBrains Mono static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font { guard hasMono else { return .system(size: size, weight: weight, design: .monospaced) } switch weight { case .bold, .heavy, .black: return .custom("JetBrainsMono-Bold", size: size) case .medium, .semibold: return .custom("JetBrainsMono-Medium", size: size) default: return .custom("JetBrainsMono-Regular", size: size) } } } // MARK: - Carte à ombre décalée (signature visuelle) struct LKCardModifier: ViewModifier { var radius: CGFloat = 10 var offset: CGFloat = 5 var borderWidth: CGFloat = 1.8 func body(content: Content) -> some View { content .background(LK.surface) .clipShape(RoundedRectangle(cornerRadius: radius)) .overlay(RoundedRectangle(cornerRadius: radius).stroke(LK.ink, lineWidth: borderWidth)) .background( RoundedRectangle(cornerRadius: radius) .fill(LK.ink) .offset(x: offset, y: offset) ) } } extension View { func lkCard(radius: CGFloat = 10, offset: CGFloat = 5, borderWidth: CGFloat = 1.8) -> some View { modifier(LKCardModifier(radius: radius, offset: offset, borderWidth: borderWidth)) } } // MARK: - Micro-composants /// Étiquette mono majuscule avec tiret vert — le « kicker » du site web struct Kicker: View { let text: String var body: some View { HStack(spacing: 8) { Rectangle().fill(LK.green).frame(width: 22, height: 2) Text(text.uppercased()) .font(LKFont.mono(11, .medium)) .kerning(1.4) .foregroundStyle(LK.green) } } } /// Pastille type de logement (encre sur lime) — ex. « 4½ » struct UnitTypeBadge: View { let type: String var body: some View { Text(type) .font(LKFont.mono(12, .bold)) .padding(.horizontal, 8) .padding(.vertical, 4) .background(LK.ink) .foregroundStyle(LK.lime) .clipShape(RoundedRectangle(cornerRadius: 5)) } } /// Puce filtre / commodité struct Chip: View { let label: String var selected = false var action: (() -> Void)? var body: some View { let core = Text(label) .font(LKFont.display(13.5, .medium)) .padding(.horizontal, 14) .padding(.vertical, 8) .background(selected ? LK.ink : LK.surface) .foregroundStyle(selected ? LK.lime : LK.ink) .clipShape(Capsule()) .overlay(Capsule().stroke(selected ? LK.ink : LK.ink.opacity(0.35), lineWidth: 1.5)) if let action { Button(action: action) { core }.buttonStyle(.plain) } else { core } } } /// Ruban défilant encre/lime — équivalent du « ticker » du site struct TickerBar: View { let text: String @State private var segWidth: CGFloat = 0 private var segment: some View { Text("\(text) ◆ ") .font(LKFont.mono(11, .medium)) .kerning(1.2) .foregroundStyle(LK.lime) .fixedSize() } var body: some View { ZStack(alignment: .leading) { // gabarit invisible : mesure la largeur d'un segment segment .hidden() .background(GeometryReader { g in Color.clear.onAppear { segWidth = g.size.width } }) if segWidth > 0 { TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { ctx in let t = ctx.date.timeIntervalSinceReferenceDate let phase = CGFloat((t * 28).truncatingRemainder(dividingBy: Double(segWidth))) HStack(spacing: 0) { segment segment segment segment } .offset(x: -phase) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 8) .background(LK.ink) .clipShape(RoundedRectangle(cornerRadius: 7)) .overlay(RoundedRectangle(cornerRadius: 7).stroke(LK.ink, lineWidth: 1.5)) } } /// Marque « Lou·Ka » — le « Ka » en encre sur lime, légèrement incliné struct BrandMark: View { var size: CGFloat = 30 var body: some View { HStack(alignment: .center, spacing: 3) { Text("Lou") .font(LKFont.display(size, .bold)) .kerning(-1) .foregroundStyle(LK.ink) Text("Ka") .font(LKFont.display(size * 0.86, .bold)) .kerning(-1) .padding(.horizontal, size * 0.24) .padding(.vertical, 2) .background(LK.ink) .foregroundStyle(LK.lime) .clipShape(RoundedRectangle(cornerRadius: 6)) .rotationEffect(.degrees(-2)) } } } // MARK: - Formatage fr-CA enum Fmt { static let priceFormatter: NumberFormatter = { let f = NumberFormatter() f.locale = Locale(identifier: "fr_CA") f.numberStyle = .decimal f.maximumFractionDigits = 0 return f }() /// 1250 → « 1 250 $ » ; nil → étiquette source ou « Prix sur demande » static func price(_ p: Double?, label: String = "") -> String { guard let p else { return label.isEmpty ? "Prix sur demande" : label } return (priceFormatter.string(from: NSNumber(value: p)) ?? "\(Int(p))") + " $" } /// Entier avec séparateur fr-CA (ex. 9 097) static func int(_ n: Int) -> String { priceFormatter.string(from: NSNumber(value: n)) ?? "\(n)" } /// "now" → « Maintenant », "2026-12-01" → « 1ᵉʳ décembre 2026 » static func availability(_ iso: String?) -> String? { guard let iso, !iso.isEmpty else { return nil } if iso == "now" { return "Maintenant" } let parts = iso.split(separator: "-").compactMap { Int($0) } guard parts.count == 3 else { return nil } var comps = DateComponents() (comps.year, comps.month, comps.day) = (parts[0], parts[1], parts[2]) guard let date = Calendar.current.date(from: comps) else { return nil } let f = DateFormatter() f.locale = Locale(identifier: "fr_CA") f.dateFormat = "d MMMM yyyy" var txt = f.string(from: date) if txt.hasPrefix("1 ") { txt = "1ᵉʳ " + txt.dropFirst(2) } return txt } /// 250 → « 250 m », 1240 → « 1,2 km » static func dist(_ m: Double) -> String { if m < 1000 { return "\(Int((m / 10).rounded()) * 10) m" } return String(format: "%.1f km", m / 1000).replacingOccurrences(of: ".", with: ",") } /// Horodatage Unix → « il y a 2 h » static func relative(_ ts: Double?) -> String? { guard let ts, ts > 0 else { return nil } let f = RelativeDateTimeFormatter() f.locale = Locale(identifier: "fr_CA") f.unitsStyle = .short return f.localizedString(for: Date(timeIntervalSince1970: ts), relativeTo: Date()) } }