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
}
}
@@ -0,0 +1,332 @@
import Foundation
struct Envelope<T: Decodable>: Decodable { let data: T }
struct PagedEnvelope<T: Decodable>: Decodable {
let data: [T]
let meta: PageMeta?
}
struct PageMeta: Decodable {
let currentPage: Int?
let lastPage: Int?
let perPage: Int?
let total: Int?
}
struct LoginRequest: Encodable {
let email: String
let password: String
let deviceName = "Ladill Mini iOS"
}
struct LoginResult: Decodable {
let token: String
let user: User
}
struct RegisterRequest: Encodable {
let name: String
let email: String
let password: String
let passwordConfirmation: String
let company: String?
let address: String
let city: String
let state: String
let country: String
let zipcode: String
let phoneCc: String
let phone: String
let mobileCc: String?
let mobile: String?
let terms: Bool
let deviceName = "Ladill Mini iOS"
}
struct User: Decodable {
let id: Int64
let publicId: String?
let name: String?
let email: String?
let avatarUrl: String?
let actingAccount: Account?
}
struct Account: Decodable {
let id: Int64
let publicId: String?
let name: String?
let email: String?
}
struct Overview: Decodable {
let currency: String?
let todayTakingsMinor: Int64?
let todayCount: Int?
let paymentQrCount: Int?
let walletBalanceMinor: Int64?
let recentPayments: [Payment]?
}
struct Payment: Decodable, Identifiable {
let id: Int64
let reference: String?
let amountMinor: Int64?
let merchantAmountMinor: Int64?
let platformFeeMinor: Int64?
let currency: String?
let status: String?
let payerName: String?
let payerEmail: String?
let payerPhone: String?
let payerNote: String?
let qrCodeId: Int64?
let qrLabel: String?
let paidAt: String?
let createdAt: String?
}
struct PaymentQr: Decodable, Identifiable {
let id: Int64
let label: String
let businessName: String?
let branchLabel: String?
let currency: String?
let shortCode: String?
let publicUrl: String?
let isActive: Bool?
let scansTotal: Int?
let previewUrl: String?
let createdAt: String?
let updatedAt: String?
}
struct CreatePaymentQrRequest: Encodable {
let label: String
let businessName: String
let branchLabel: String?
}
struct UpdatePaymentQrRequest: Encodable {
var label: String?
var businessName: String?
var branchLabel: String?
var isActive: Bool?
}
struct Payouts: Decodable {
let currency: String?
let totalRevenueMinor: Int64?
let walletBalanceMinor: Int64?
let accountWalletUrl: String?
}
struct AccountSettings: Decodable {
let profile: ProfileInfo
let notifications: NotificationPrefs
}
struct ProfileInfo: Decodable {
let name: String?
let email: String?
let phone: String?
let phoneCc: String?
}
struct NotificationPrefs: Codable {
var notifyEmail: String?
var productUpdates: Bool?
var notifyRegistrations: Bool?
var notifyPayouts: Bool?
}
struct UpdateSettingsRequest: Encodable {
let notifyEmail: String?
let productUpdates: Bool
let notifyRegistrations: Bool
let notifyPayouts: Bool
}
struct UpdateProfileRequest: Encodable {
let name: String
let phoneCc: String?
let phone: String?
}
struct AvatarResult: Decodable { let avatarUrl: String? }
struct ChangePasswordRequest: Encodable {
let currentPassword: String
let password: String
let passwordConfirmation: String
}
struct MessageResponse: Decodable { let message: String? }
struct AfiaChatRequest: Encodable {
let message: String
let history: [AfiaChatTurn]
}
struct AfiaChatTurn: Encodable {
let role: String
let text: String
}
struct AfiaChatResponse: Decodable {
let reply: String?
let message: String?
}
struct WalletInfo: Decodable {
let currency: String?
let balanceMinor: Int64?
let spentMinor: Int64?
let creditedMinor: Int64?
}
struct TopupRequest: Encodable { let amount: Double }
struct CheckoutUrl: Decodable { let checkoutUrl: String }
struct PayoutAccountWrapper: Decodable { let payoutAccount: PayoutAccount? }
struct PayoutAccount: Decodable {
let accountType: String?
let accountName: String?
let accountNumber: String?
let bankCode: String?
let bankName: String?
let currency: String?
}
struct UpdatePayoutAccountRequest: Encodable {
let accountType: String
let accountName: String
let accountNumber: String
let bankCode: String
let bankName: String
let currency: String
}
struct Bank: Decodable, Identifiable {
var id: String { code }
let name: String
let code: String
}
struct WithdrawRequest: Encodable { let amount: Double }
struct Withdrawal: Decodable, Identifiable {
let id: Int64
let amountGhs: Double?
let status: String?
let createdAt: String?
}
struct SupportTicket: Decodable, Identifiable {
let id: Int64
let ticketRef: String?
let subject: String?
let status: String?
let priority: String?
let hasReply: Bool?
let createdAt: String?
let message: String?
let reply: String?
let repliedAt: String?
}
struct CreateTicketRequest: Encodable {
let subject: String
let message: String
let priority: String
}
struct NotificationsListResponse: Decodable {
let data: [AppNotification]
let unreadCount: Int?
}
struct AppNotification: Decodable, Identifiable {
let id: String
let type: String?
let title: String
let message: String
let icon: String?
let milestone: String?
let url: String?
let readAt: String?
let createdAt: String?
var isRead: Bool { readAt != nil }
}
struct UnreadCountEnvelope: Decodable { let unreadCount: Int? }
struct PushTokenRequest: Encodable {
let token: String
let platform = "ios"
let deviceName = "Ladill Mini iOS"
}
struct Country: Identifiable, Hashable {
var id: String { code }
let code: String
let name: String
let dialCode: String
let states: [String]
}
enum Countries {
static let all: [Country] = [
Country(code: "GH", name: "Ghana", dialCode: "233", states: [
"Ahafo", "Ashanti", "Bono", "Bono East", "Central", "Eastern", "Greater Accra",
"North East", "Northern", "Oti", "Savannah", "Upper East", "Upper West", "Volta",
"Western", "Western North",
]),
Country(code: "NG", name: "Nigeria", dialCode: "234", states: [
"Abia", "Abuja FCT", "Adamawa", "Akwa Ibom", "Anambra", "Bauchi", "Bayelsa", "Benue",
"Borno", "Cross River", "Delta", "Ebonyi", "Edo", "Ekiti", "Enugu", "Gombe", "Imo",
"Jigawa", "Kaduna", "Kano", "Katsina", "Kebbi", "Kogi", "Kwara", "Lagos", "Nasarawa",
"Niger", "Ogun", "Ondo", "Osun", "Oyo", "Plateau", "Rivers", "Sokoto", "Taraba",
"Yobe", "Zamfara",
]),
Country(code: "KE", name: "Kenya", dialCode: "254", states: [
"Baringo", "Bomet", "Bungoma", "Busia", "Elgeyo-Marakwet", "Embu", "Garissa",
"Homa Bay", "Isiolo", "Kajiado", "Kakamega", "Kericho", "Kiambu", "Kilifi",
"Kirinyaga", "Kisii", "Kisumu", "Kitui", "Kwale", "Laikipia", "Lamu", "Machakos",
"Makueni", "Mandera", "Marsabit", "Meru", "Migori", "Mombasa", "Murang'a", "Nairobi",
"Nakuru", "Nandi", "Narok", "Nyamira", "Nyandarua", "Nyeri", "Samburu", "Siaya",
"Taita-Taveta", "Tana River", "Tharaka-Nithi", "Trans Nzoia", "Turkana",
"Uasin Gishu", "Vihiga", "Wajir", "West Pokot",
]),
Country(code: "ZA", name: "South Africa", dialCode: "27", states: [
"Eastern Cape", "Free State", "Gauteng", "KwaZulu-Natal", "Limpopo", "Mpumalanga",
"North West", "Northern Cape", "Western Cape",
]),
Country(code: "GB", name: "United Kingdom", dialCode: "44", states: [
"England", "Northern Ireland", "Scotland", "Wales",
]),
Country(code: "US", name: "United States", dialCode: "1", states: [
"Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut",
"Delaware", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa",
"Kansas", "Kentucky", "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan",
"Minnesota", "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada",
"New Hampshire", "New Jersey", "New Mexico", "New York", "North Carolina",
"North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania", "Rhode Island",
"South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont",
"Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming",
]),
]
static var `default`: Country { all[0] }
}
enum LoadState<T> {
case idle
case loading
case success(T)
case failure(String)
}