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>
243 lines
10 KiB
Swift
243 lines
10 KiB
Swift
import SwiftUI
|
|
|
|
struct OverviewTab: View {
|
|
@ObservedObject var vm: HomeViewModel
|
|
let onCreateQr: () -> Void
|
|
let onSeePayments: () -> Void
|
|
let onOpenWallet: () -> Void
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
let firstName = SessionStore.shared.name?
|
|
.trimmingCharacters(in: .whitespaces)
|
|
.split(separator: " ").first.map(String.init)
|
|
|
|
Text(firstName.map { "Hello, \($0)" } ?? "Hello")
|
|
.font(.ladill(size: 22, weight: .semibold))
|
|
.foregroundStyle(LadillColors.ink)
|
|
Text("Here is today's takings, your payment QRs, and recent payments.")
|
|
.font(.ladill(size: 14))
|
|
.foregroundStyle(LadillColors.muted)
|
|
|
|
if vm.hasPayout == false {
|
|
payoutNote
|
|
}
|
|
|
|
switch vm.overviewState {
|
|
case .idle, .loading:
|
|
LoadingView()
|
|
.task { await vm.loadOverview() }
|
|
case .failure(let message):
|
|
ErrorView(message: message) { Task { await vm.loadOverview() } }
|
|
case .success(let data):
|
|
overviewContent(data)
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
.refreshable { await vm.refreshOverview() }
|
|
}
|
|
|
|
private var payoutNote: some View {
|
|
Button(action: onOpenWallet) {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.foregroundStyle(LadillColors.indigo)
|
|
Text("Set up your payout account to withdraw takings.")
|
|
.font(.ladill(size: 13))
|
|
.foregroundStyle(LadillColors.indigoText)
|
|
Spacer()
|
|
}
|
|
.padding(14)
|
|
.background(LadillColors.indigoSoft)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func overviewContent(_ data: Overview) -> some View {
|
|
let currency = data.currency ?? "GHS"
|
|
HStack(spacing: 12) {
|
|
StatCard(label: "Today", value: Formatters.money(minor: data.todayTakingsMinor ?? 0, currency: currency), accent: true)
|
|
StatCard(label: "Payments", value: "\(data.todayCount ?? 0)")
|
|
}
|
|
HStack(spacing: 12) {
|
|
StatCard(label: "Payment QRs", value: "\(data.paymentQrCount ?? 0)")
|
|
StatCard(label: "Wallet", value: Formatters.money(minor: data.walletBalanceMinor ?? 0, currency: currency))
|
|
}
|
|
|
|
LadillButton(title: "Create payment QR", action: onCreateQr)
|
|
.padding(.top, 4)
|
|
|
|
if let payments = data.recentPayments, !payments.isEmpty {
|
|
HStack {
|
|
Text("Recent payments")
|
|
.font(.ladill(size: 16, weight: .semibold))
|
|
Spacer()
|
|
Button("See all", action: onSeePayments)
|
|
.font(.ladill(size: 13, weight: .semibold))
|
|
.foregroundStyle(LadillColors.indigo)
|
|
}
|
|
.padding(.top, 8)
|
|
|
|
ForEach(payments) { payment in
|
|
paymentRow(payment)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func paymentRow(_ payment: Payment) -> some View {
|
|
LadillCard {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(payment.payerName ?? payment.qrLabel ?? "Payment")
|
|
.font(.ladill(size: 15, weight: .semibold))
|
|
Text(Formatters.date(iso: payment.paidAt ?? payment.createdAt))
|
|
.font(.ladill(size: 12))
|
|
.foregroundStyle(LadillColors.muted)
|
|
}
|
|
Spacer()
|
|
Text(Formatters.money(minor: payment.amountMinor ?? 0, currency: payment.currency ?? "GHS"))
|
|
.font(.ladill(size: 15, weight: .semibold))
|
|
.foregroundStyle(LadillColors.money)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PaymentQrsTab: View {
|
|
@ObservedObject var vm: HomeViewModel
|
|
let onCreateQr: () -> Void
|
|
let onOpenQr: (Int64) -> Void
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 12) {
|
|
LadillButton(title: "Create payment QR", action: onCreateQr)
|
|
|
|
switch vm.qrsState {
|
|
case .idle, .loading:
|
|
LoadingView().task { await vm.loadQrs() }
|
|
case .failure(let message):
|
|
ErrorView(message: message) { Task { await vm.loadQrs() } }
|
|
case .success(let qrs):
|
|
if qrs.isEmpty {
|
|
Text("No payment QRs yet. Create one to start collecting.")
|
|
.font(.ladill(size: 14))
|
|
.foregroundStyle(LadillColors.muted)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.vertical, 40)
|
|
} else {
|
|
ForEach(qrs) { qr in
|
|
Button { onOpenQr(qr.id) } label: {
|
|
LadillCard {
|
|
HStack {
|
|
Image(systemName: "qrcode")
|
|
.font(.system(size: 28))
|
|
.foregroundStyle(LadillColors.indigo)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(qr.label)
|
|
.font(.ladill(size: 15, weight: .semibold))
|
|
.foregroundStyle(LadillColors.ink)
|
|
Text(qr.businessName ?? "")
|
|
.font(.ladill(size: 12))
|
|
.foregroundStyle(LadillColors.muted)
|
|
}
|
|
Spacer()
|
|
if qr.isActive == false {
|
|
Text("Paused")
|
|
.font(.ladill(size: 11, weight: .medium))
|
|
.foregroundStyle(LadillColors.muted)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
.refreshable { await vm.loadQrs() }
|
|
}
|
|
}
|
|
|
|
struct PaymentsTab: View {
|
|
@ObservedObject var vm: HomeViewModel
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 12) {
|
|
switch vm.paymentsState {
|
|
case .idle, .loading:
|
|
LoadingView().task { await vm.loadPayments() }
|
|
case .failure(let message):
|
|
ErrorView(message: message) { Task { await vm.loadPayments() } }
|
|
case .success(let payments):
|
|
if payments.isEmpty {
|
|
Text("No payments yet.")
|
|
.font(.ladill(size: 14))
|
|
.foregroundStyle(LadillColors.muted)
|
|
.padding(.vertical, 40)
|
|
} else {
|
|
ForEach(payments) { payment in
|
|
LadillCard {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(payment.payerName ?? payment.qrLabel ?? "Payment")
|
|
.font(.ladill(size: 15, weight: .semibold))
|
|
Text(Formatters.date(iso: payment.paidAt ?? payment.createdAt))
|
|
.font(.ladill(size: 12))
|
|
.foregroundStyle(LadillColors.muted)
|
|
}
|
|
Spacer()
|
|
Text(Formatters.money(minor: payment.amountMinor ?? 0, currency: payment.currency ?? "GHS"))
|
|
.font(.ladill(size: 15, weight: .semibold))
|
|
.foregroundStyle(LadillColors.money)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
}
|
|
.refreshable { await vm.loadPayments() }
|
|
}
|
|
}
|
|
|
|
struct PayoutsTab: View {
|
|
@ObservedObject var vm: HomeViewModel
|
|
let onOpenWallet: () -> Void
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Payouts")
|
|
.font(.ladill(size: 22, weight: .semibold))
|
|
Text("Takings settle into your Ladill wallet (5% fee), then withdraw to bank or MoMo.")
|
|
.font(.ladill(size: 14))
|
|
.foregroundStyle(LadillColors.muted)
|
|
|
|
switch vm.payoutsState {
|
|
case .idle, .loading:
|
|
LoadingView().task { await vm.loadPayouts() }
|
|
case .failure(let message):
|
|
ErrorView(message: message) { Task { await vm.loadPayouts() } }
|
|
case .success(let data):
|
|
let currency = data.currency ?? "GHS"
|
|
StatCard(label: "Total revenue", value: Formatters.money(minor: data.totalRevenueMinor ?? 0, currency: currency), accent: true)
|
|
StatCard(label: "Wallet balance", value: Formatters.money(minor: data.walletBalanceMinor ?? 0, currency: currency))
|
|
}
|
|
|
|
LadillButton(title: "Open wallet", action: onOpenWallet)
|
|
}
|
|
.padding(20)
|
|
}
|
|
.refreshable { await vm.loadPayouts() }
|
|
}
|
|
}
|