Files
ladill-mini/apps/ios/LadillMini/Features/Account/AccountViews.swift
T
isaaccladandCursor ea6afb80a4
Deploy Ladill Mini / deploy (push) Successful in 35s
Add Ladill Mini native Android and iOS apps.
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>
2026-06-11 22:30:35 +00:00

402 lines
16 KiB
Swift

import SwiftUI
struct ProfileView: View {
let onBack: () -> Void
@State private var name = SessionStore.shared.name ?? ""
@State private var phone = ""
@State private var phoneCc = Countries.default.dialCode
@State private var loading = false
@State private var saving = false
@State private var message: String?
@State private var error: String?
var body: some View {
MenuScaffold(title: "Profile", onBack: onBack) {
ScrollView {
VStack(spacing: 16) {
LadillTextField(label: "Name", placeholder: "Your name", text: $name)
LadillTextField(label: "Phone", placeholder: "Mobile number", text: $phone, keyboard: .phonePad)
if let message {
Text(message).font(.ladill(size: 13)).foregroundStyle(LadillColors.money)
}
if let error {
Text(error).font(.ladill(size: 13)).foregroundStyle(LadillColors.red)
}
LadillButton(title: "Save changes", loading: saving, enabled: !name.isEmpty) {
Task { await save() }
}
}
.padding(20)
}
.task { await load() }
}
}
private func load() async {
loading = true
defer { loading = false }
if let settings = try? await APIClient.shared.accountSettings() {
name = settings.profile.name ?? name
phone = settings.profile.phone ?? ""
phoneCc = settings.profile.phoneCc ?? phoneCc
}
}
private func save() async {
saving = true
error = nil
message = nil
defer { saving = false }
do {
let profile = try await APIClient.shared.updateProfile(UpdateProfileRequest(
name: name.trimmingCharacters(in: .whitespaces),
phoneCc: phoneCc,
phone: phone.trimmingCharacters(in: .whitespaces).isEmpty ? nil : phone.trimmingCharacters(in: .whitespaces)
))
SessionStore.shared.updateName(profile.name)
message = "Profile updated."
} catch {
self.error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
}
}
struct SettingsView: View {
let onBack: () -> Void
@State private var notifyEmail = ""
@State private var productUpdates = true
@State private var notifyRegistrations = true
@State private var notifyPayouts = true
@State private var saving = false
@State private var message: String?
@State private var error: String?
var body: some View {
MenuScaffold(title: "Settings", onBack: onBack) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Notifications")
.font(.ladill(size: 16, weight: .semibold))
LadillTextField(label: "Notification email", placeholder: "Optional", text: $notifyEmail, keyboard: .emailAddress)
Toggle("Product updates", isOn: $productUpdates).tint(LadillColors.indigo)
Toggle("Payment alerts", isOn: $notifyRegistrations).tint(LadillColors.indigo)
Toggle("Payout updates", isOn: $notifyPayouts).tint(LadillColors.indigo)
if let message {
Text(message).font(.ladill(size: 13)).foregroundStyle(LadillColors.money)
}
if let error {
Text(error).font(.ladill(size: 13)).foregroundStyle(LadillColors.red)
}
LadillButton(title: "Save", loading: saving) {
Task { await save() }
}
}
.padding(20)
}
.task { await load() }
}
}
private func load() async {
if let settings = try? await APIClient.shared.accountSettings() {
notifyEmail = settings.notifications.notifyEmail ?? ""
productUpdates = settings.notifications.productUpdates ?? true
notifyRegistrations = settings.notifications.notifyRegistrations ?? true
notifyPayouts = settings.notifications.notifyPayouts ?? true
}
}
private func save() async {
saving = true
error = nil
message = nil
defer { saving = false }
do {
_ = try await APIClient.shared.updateSettings(UpdateSettingsRequest(
notifyEmail: notifyEmail.trimmingCharacters(in: .whitespaces).isEmpty ? nil : notifyEmail.trimmingCharacters(in: .whitespaces),
productUpdates: productUpdates,
notifyRegistrations: notifyRegistrations,
notifyPayouts: notifyPayouts
))
message = "Settings saved."
} catch {
self.error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
}
}
struct SecurityView: View {
let onBack: () -> Void
@State private var currentPassword = ""
@State private var password = ""
@State private var confirmation = ""
@State private var saving = false
@State private var message: String?
@State private var error: String?
var body: some View {
MenuScaffold(title: "Security", onBack: onBack) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Change password")
.font(.ladill(size: 16, weight: .semibold))
LadillTextField(label: "Current password", placeholder: "••••••••", text: $currentPassword, secure: true)
LadillTextField(label: "New password", placeholder: "At least 8 characters", text: $password, secure: true)
LadillTextField(label: "Confirm password", placeholder: "Repeat password", text: $confirmation, secure: true)
if PinStore.shared.hasPin() {
Button("Remove app PIN") {
PinStore.shared.clearPin()
message = "PIN removed."
}
.font(.ladill(size: 14, weight: .semibold))
.foregroundStyle(LadillColors.indigo)
} else {
Text("Set an app PIN from your device settings after your next sign-in.")
.font(.ladill(size: 13))
.foregroundStyle(LadillColors.muted)
}
if let message {
Text(message).font(.ladill(size: 13)).foregroundStyle(LadillColors.money)
}
if let error {
Text(error).font(.ladill(size: 13)).foregroundStyle(LadillColors.red)
}
LadillButton(title: "Update password", loading: saving, enabled: password.count >= 8) {
Task { await save() }
}
}
.padding(20)
}
}
}
private func save() async {
if password != confirmation {
error = "Passwords do not match."
return
}
saving = true
error = nil
message = nil
defer { saving = false }
do {
let response = try await APIClient.shared.changePassword(ChangePasswordRequest(
currentPassword: currentPassword,
password: password,
passwordConfirmation: confirmation
))
message = response.message ?? "Password updated."
currentPassword = ""
password = ""
confirmation = ""
} catch {
self.error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
}
}
struct WalletView: View {
let onBack: () -> Void
@State private var wallet: WalletInfo?
@State private var payout: PayoutAccount?
@State private var withdrawals: [Withdrawal] = []
@State private var amount = ""
@State private var loading = true
@State private var error: String?
var body: some View {
MenuScaffold(title: "Wallet", onBack: onBack) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if loading {
LoadingView()
} else if let wallet {
StatCard(
label: "Balance",
value: Formatters.money(minor: wallet.balanceMinor ?? 0, currency: wallet.currency ?? "GHS"),
accent: true
)
Text("Ladill Mini deducts a small platform fee from each payment you collect. Your balance is what's left to withdraw.")
.font(.ladill(size: 13))
.foregroundStyle(LadillColors.muted)
if let payout {
LadillCard {
VStack(alignment: .leading, spacing: 4) {
Text("Payout account")
.font(.ladill(size: 12, weight: .medium))
.foregroundStyle(LadillColors.muted)
Text(payout.accountName ?? "")
Text(payout.bankName ?? payout.accountType ?? "")
.font(.ladill(size: 13))
.foregroundStyle(LadillColors.muted)
}
}
}
LadillTextField(label: "Withdraw amount (GHS)", placeholder: "0.00", text: $amount, keyboard: .decimalPad)
LadillButton(title: "Request withdrawal", enabled: Double(amount) ?? 0 > 0) {
Task { await withdraw() }
}
if !withdrawals.isEmpty {
Text("Recent withdrawals")
.font(.ladill(size: 16, weight: .semibold))
.padding(.top, 8)
ForEach(withdrawals) { w in
LadillCard {
HStack {
Text(Formatters.money(minor: Int64((w.amountGhs ?? 0) * 100), currency: "GHS"))
Spacer()
Text(w.status?.capitalized ?? "Pending")
.font(.ladill(size: 12))
.foregroundStyle(LadillColors.muted)
}
}
}
}
}
if let error {
Text(error).font(.ladill(size: 13)).foregroundStyle(LadillColors.red)
}
}
.padding(20)
}
.task { await load() }
}
}
private func load() async {
loading = true
defer { loading = false }
do {
wallet = try await APIClient.shared.wallet()
payout = try await APIClient.shared.payoutAccount()
withdrawals = try await APIClient.shared.withdrawals()
} catch {
self.error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
}
private func withdraw() async {
guard let value = Double(amount), value > 0 else { return }
do {
_ = try await APIClient.shared.withdraw(amount: value)
amount = ""
await load()
} catch {
self.error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
}
}
struct BillingView: View {
let onBack: () -> Void
var body: some View {
MenuScaffold(title: "Billing", onBack: onBack) {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("Usage & platform fees")
.font(.ladill(size: 16, weight: .semibold))
Text("Ladill Mini charges a 5% platform fee on payments you collect. There are no monthly fees for payment QRs.")
.font(.ladill(size: 14))
.foregroundStyle(LadillColors.muted)
LadillCard {
VStack(alignment: .leading, spacing: 8) {
Text("Platform fee")
.font(.ladill(size: 12, weight: .medium))
.foregroundStyle(LadillColors.muted)
Text("5% per payment")
.font(.ladill(size: 20, weight: .semibold))
.foregroundStyle(LadillColors.indigoText)
}
}
}
.padding(20)
}
}
}
}
struct SupportView: View {
let onBack: () -> Void
@State private var tickets: [SupportTicket] = []
@State private var subject = ""
@State private var message = ""
@State private var loading = true
@State private var sending = false
@State private var error: String?
var body: some View {
MenuScaffold(title: "Support", onBack: onBack) {
ScrollView {
VStack(spacing: 16) {
LadillTextField(label: "Subject", placeholder: "How can we help?", text: $subject)
LadillTextField(label: "Message", placeholder: "Describe your issue", text: $message)
LadillButton(title: "Submit ticket", loading: sending, enabled: !subject.isEmpty && !message.isEmpty) {
Task { await submit() }
}
if !tickets.isEmpty {
Text("Your tickets")
.font(.ladill(size: 16, weight: .semibold))
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(tickets) { ticket in
LadillCard {
VStack(alignment: .leading, spacing: 4) {
Text(ticket.subject ?? "")
.font(.ladill(size: 15, weight: .semibold))
Text(ticket.status?.capitalized ?? "Open")
.font(.ladill(size: 12))
.foregroundStyle(LadillColors.muted)
}
}
}
}
if let error {
Text(error).font(.ladill(size: 13)).foregroundStyle(LadillColors.red)
}
}
.padding(20)
}
.task { await load() }
}
}
private func load() async {
loading = true
defer { loading = false }
tickets = (try? await APIClient.shared.supportTickets()) ?? []
}
private func submit() async {
sending = true
error = nil
defer { sending = false }
do {
_ = try await APIClient.shared.createSupportTicket(CreateTicketRequest(
subject: subject.trimmingCharacters(in: .whitespaces),
message: message.trimmingCharacters(in: .whitespaces),
priority: "normal"
))
subject = ""
message = ""
await load()
} catch {
self.error = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
}
}