Deploy Ladill Mini / deploy (push) Successful in 35s
Ship full-featured mobile clients with auth, payments, QRs, push notifications, and iOS polish including Figtree typography, app icons, and QR previews. Co-authored-by: Cursor <cursoragent@cursor.com>
140 lines
4.6 KiB
Swift
140 lines
4.6 KiB
Swift
import CoreImage.CIFilterBuiltins
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
/// Renders a QR code on-device from the payment link. Offline and auth-free.
|
|
struct QrImage: View {
|
|
let content: String
|
|
var size: CGFloat = 220
|
|
|
|
private var image: UIImage? {
|
|
guard let data = content.data(using: .utf8) else { return nil }
|
|
let filter = CIFilter.qrCodeGenerator()
|
|
filter.setValue(data, forKey: "inputMessage")
|
|
filter.setValue("M", forKey: "inputCorrectionLevel")
|
|
|
|
guard let output = filter.outputImage else { return nil }
|
|
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
|
|
let context = CIContext()
|
|
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
|
|
|
|
return UIImage(cgImage: cgImage).withTintColors(
|
|
dark: UIColor(LadillColors.ink),
|
|
light: UIColor(LadillColors.surface)
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
if let image {
|
|
Image(uiImage: image)
|
|
.interpolation(.none)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: size, height: size)
|
|
.accessibilityLabel("QR code")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Branded QR preview from the API, with on-device fallback.
|
|
struct PaymentQrPreview: View {
|
|
let publicUrl: String?
|
|
let previewUrl: String?
|
|
|
|
var size: CGFloat = 220
|
|
|
|
@State private var previewImage: UIImage?
|
|
@State private var loadFailed = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let previewImage {
|
|
Image(uiImage: previewImage)
|
|
.resizable()
|
|
.interpolation(.high)
|
|
.scaledToFit()
|
|
.frame(width: size, height: size)
|
|
.accessibilityLabel("QR code preview")
|
|
} else if loadFailed, let encoded = publicUrl {
|
|
QrImage(content: encoded, size: size)
|
|
} else if publicUrl == nil && previewUrl == nil {
|
|
Color.clear.frame(width: size, height: size)
|
|
} else {
|
|
ProgressView()
|
|
.frame(width: size, height: size)
|
|
}
|
|
}
|
|
.task(id: previewUrl) {
|
|
await loadPreview()
|
|
}
|
|
}
|
|
|
|
private func loadPreview() async {
|
|
previewImage = nil
|
|
loadFailed = false
|
|
|
|
guard let previewUrl, let url = URL(string: previewUrl) else {
|
|
loadFailed = true
|
|
return
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.setValue("image/*", forHTTPHeaderField: "Accept")
|
|
if let token = await SessionStore.shared.token {
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
}
|
|
|
|
do {
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode),
|
|
let image = UIImage(data: data) else {
|
|
loadFailed = true
|
|
return
|
|
}
|
|
previewImage = image
|
|
} catch {
|
|
loadFailed = true
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension UIImage {
|
|
func withTintColors(dark: UIColor, light: UIColor) -> UIImage {
|
|
guard let cgImage else { return self }
|
|
let width = cgImage.width
|
|
let height = cgImage.height
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
guard let context = CGContext(
|
|
data: nil,
|
|
width: width,
|
|
height: height,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: width * 4,
|
|
space: colorSpace,
|
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
|
) else { return self }
|
|
|
|
context.interpolationQuality = .none
|
|
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
|
|
guard let data = context.data else { return self }
|
|
let pixels = data.bindMemory(to: UInt8.self, capacity: width * height * 4)
|
|
for y in 0..<height {
|
|
for x in 0..<width {
|
|
let offset = (y * width + x) * 4
|
|
let isDark = pixels[offset] < 128
|
|
let color = isDark ? dark : light
|
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
|
color.getRed(&r, green: &g, blue: &b, alpha: &a)
|
|
pixels[offset] = UInt8(r * 255)
|
|
pixels[offset + 1] = UInt8(g * 255)
|
|
pixels[offset + 2] = UInt8(b * 255)
|
|
pixels[offset + 3] = 255
|
|
}
|
|
}
|
|
|
|
guard let tinted = context.makeImage() else { return self }
|
|
return UIImage(cgImage: tinted)
|
|
}
|
|
}
|