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>
402 lines
16 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|