Add Ladill Mini native Android and iOS apps.
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>
This commit is contained in:
isaacclad
2026-06-11 22:30:35 +00:00
co-authored by Cursor
parent ec9f09f997
commit ea6afb80a4
172 changed files with 12114 additions and 0 deletions
@@ -0,0 +1,277 @@
import Foundation
enum APIError: LocalizedError {
case invalidURL
case http(Int, String?)
case decoding(Error)
case network(Error)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid request."
case .http(let code, let message):
if let message, !message.isEmpty { return message }
if code == 401 { return "Your session expired. Please sign in again." }
if code == 403 { return "You don't have permission to do that." }
return "Request failed (\(code))."
case .decoding: return "Could not read the server response."
case .network: return "No connection. Check your network and try again."
}
}
}
final class APIClient {
static let shared = APIClient()
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
private var baseURL: String {
Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String
?? "https://mini.ladill.com/api/v1/"
}
private init() {
session = URLSession.shared
decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
}
// MARK: - Auth
func login(email: String, password: String) async throws -> LoginResult {
try await envelopePost("auth/login", body: LoginRequest(email: email, password: password))
}
func register(_ body: RegisterRequest) async throws -> LoginResult {
try await envelopePost("auth/register", body: body)
}
func logout() async {
_ = try? await voidRequest("auth/logout", method: "POST")
}
func me() async throws -> User {
try await envelopeGet("me")
}
// MARK: - Mini
func overview() async throws -> Overview { try await envelopeGet("mini/overview") }
func afiaChat(message: String, history: [AfiaChatTurn]) async throws -> AfiaChatResponse {
try await request("mini/afia/chat", method: "POST", body: AfiaChatRequest(message: message, history: history))
}
func paymentQrs() async throws -> [PaymentQr] { try await envelopeGet("mini/payment-qrs") }
func createPaymentQr(_ body: CreatePaymentQrRequest) async throws -> PaymentQr {
try await envelopePost("mini/payment-qrs", body: body)
}
func paymentQr(id: Int64) async throws -> PaymentQr {
try await envelopeGet("mini/payment-qrs/\(id)")
}
func updatePaymentQr(id: Int64, body: UpdatePaymentQrRequest) async throws -> PaymentQr {
try await envelopePatch("mini/payment-qrs/\(id)", body: body)
}
func deletePaymentQr(id: Int64) async throws {
try await voidRequest("mini/payment-qrs/\(id)", method: "DELETE")
}
func payments(query: String? = nil) async throws -> [Payment] {
var path = "mini/payments"
if let query, !query.isEmpty,
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
path += "?q=\(encoded)"
}
return try await envelopeGet(path)
}
func payouts() async throws -> Payouts { try await envelopeGet("mini/payouts") }
func accountSettings() async throws -> AccountSettings {
try await envelopeGet("mini/account/settings")
}
func updateSettings(_ body: UpdateSettingsRequest) async throws -> NotificationPrefs {
try await envelopePut("mini/account/settings", body: body)
}
func updateProfile(_ body: UpdateProfileRequest) async throws -> ProfileInfo {
try await envelopePut("mini/account/profile", body: body)
}
func changePassword(_ body: ChangePasswordRequest) async throws -> MessageResponse {
try await envelopePost("mini/account/change-password", body: body)
}
func wallet() async throws -> WalletInfo { try await envelopeGet("mini/wallet") }
func walletTopup(amount: Double) async throws -> CheckoutUrl {
try await envelopePost("mini/wallet/topup", body: TopupRequest(amount: amount))
}
func banks(type: String) async throws -> [Bank] {
try await envelopeGet("mini/wallet/banks?type=\(type)")
}
func payoutAccount() async throws -> PayoutAccount? {
let wrapper: PayoutAccountWrapper = try await envelopeGet("mini/wallet/payout-account")
return wrapper.payoutAccount
}
func updatePayoutAccount(_ body: UpdatePayoutAccountRequest) async throws -> PayoutAccount? {
let wrapper: PayoutAccountWrapper = try await envelopePut("mini/wallet/payout-account", body: body)
return wrapper.payoutAccount
}
func withdrawals() async throws -> [Withdrawal] {
try await envelopeGet("mini/wallet/withdrawals")
}
func withdraw(amount: Double) async throws -> Withdrawal {
try await envelopePost("mini/wallet/withdraw", body: WithdrawRequest(amount: amount))
}
func supportTickets() async throws -> [SupportTicket] {
try await envelopeGet("mini/support/tickets")
}
func createSupportTicket(_ body: CreateTicketRequest) async throws -> SupportTicket {
try await envelopePost("mini/support/tickets", body: body)
}
func supportTicket(id: Int64) async throws -> SupportTicket {
try await envelopeGet("mini/support/tickets/\(id)")
}
func notifications() async throws -> NotificationsListResponse {
try await request("mini/notifications", method: "GET")
}
func unreadNotificationCount() async throws -> Int {
let envelope: Envelope<UnreadCountEnvelope> = try await request("mini/notifications/unread-count", method: "GET")
return envelope.data.unreadCount ?? 0
}
func markNotificationRead(id: String) async throws {
try await voidRequest("mini/notifications/\(id)/read", method: "POST")
}
func markAllNotificationsRead() async throws {
try await voidRequest("mini/notifications/mark-all-read", method: "POST")
}
func registerPushToken(_ body: PushTokenRequest) async throws {
try await voidRequest("mini/push-token", method: "POST", body: body)
}
func unregisterPushToken(token: String) async throws {
try await voidRequest("mini/push-token", method: "DELETE", body: PushTokenRequest(token: token))
}
// MARK: - Helpers
private func envelopeGet<T: Decodable>(_ path: String) async throws -> T {
let envelope: Envelope<T> = try await request(path, method: "GET")
return envelope.data
}
private func envelopePost<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
let envelope: Envelope<T> = try await request(path, method: "POST", body: body)
return envelope.data
}
private func envelopePut<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
let envelope: Envelope<T> = try await request(path, method: "PUT", body: body)
return envelope.data
}
private func envelopePatch<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
let envelope: Envelope<T> = try await request(path, method: "PATCH", body: body)
return envelope.data
}
private func voidRequest(_ path: String, method: String) async throws {
let _: Data = try await rawRequest(path, method: method, body: Optional<EmptyBody>.none)
}
private func voidRequest<B: Encodable>(_ path: String, method: String, body: B) async throws {
let _: Data = try await rawRequest(path, method: method, body: body)
}
private func request<T: Decodable>(_ path: String, method: String) async throws -> T {
let data = try await rawRequest(path, method: method, body: Optional<EmptyBody>.none)
do {
return try decoder.decode(T.self, from: data)
} catch let error as DecodingError {
throw APIError.decoding(error)
}
}
private func request<T: Decodable, B: Encodable>(
_ path: String,
method: String,
body: B
) async throws -> T {
let data = try await rawRequest(path, method: method, body: body)
do {
return try decoder.decode(T.self, from: data)
} catch let error as DecodingError {
throw APIError.decoding(error)
}
}
private struct EmptyBody: Encodable {}
private func rawRequest<B: Encodable>(_ path: String, method: String, body: B?) async throws -> Data {
guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
if body != nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let token = await SessionStore.shared.token
if let token, !token.isEmpty {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
request.httpBody = try encoder.encode(body)
}
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIError.http(0, nil)
}
if http.statusCode >= 400 {
throw APIError.http(http.statusCode, Self.parseErrorMessage(data))
}
return data
} catch let error as APIError {
throw error
} catch {
throw APIError.network(error)
}
}
private static func parseErrorMessage(_ data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
if let errors = json["errors"] as? [String: Any],
let first = errors.values.first as? [String],
let message = first.first {
return message
}
return json["message"] as? String
}
}