-
-
Notifications
You must be signed in to change notification settings - Fork 38
Description
// Anwar Bank – SwiftUI Starter App (iOS 17+) // Unique features for Pakistan market: Zakat calculator, savings jars (Goals), // Urdu/English localization-ready strings, offline QR receive (mock), CNIC mask. // This is a single-file scaffold to paste into a fresh SwiftUI project and iterate. // Create a new Xcode iOS App project named "AnwarBank" and replace ContentView.swift // with this file's contents. Add proper signing and capabilities before shipping.
import SwiftUI import LocalAuthentication import Combine
// MARK: - App Entry @main struct AnwarBankApp: App { @StateObject private var auth = AuthViewModel() @StateObject private var router = Router()
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(auth)
.environmentObject(router)
}
}
}
// MARK: - Router final class Router: ObservableObject { @published var path = NavigationPath() func reset() { path = NavigationPath() } }
// MARK: - Root struct RootView: View { @EnvironmentObject var auth: AuthViewModel
var body: some View {
Group {
if auth.phase == .authenticated {
MainTabView()
} else if auth.phase == .otpPending {
OTPView()
} else {
LoginView()
}
}
.tint(.accentColor)
}
}
// MARK: - Design Tokens extension Color { static let abPrimary = Color("ABPrimary") // Add in Assets: a rich emerald static let abBg = Color(UIColor.systemGroupedBackground) }
// MARK: - Models struct User: Codable, Identifiable { let id: String var fullName: String var phone: String // E.164 (e.g., +923001234567) var cnic: String // 13 digits, no dashes stored; UI masks }
enum AccountType: String, Codable, CaseIterable { case current, savings, islamicSavings }
struct Account: Codable, Identifiable, Hashable { let id: String let type: AccountType var iban: String // IBAN-like; mock e.g. PK98ANWR0000... var balancePKR: Decimal }
struct Txn: Codable, Identifiable, Hashable { enum Kind: String, Codable { case debit, credit } let id: String let accountId: String let date: Date let description: String let amountPKR: Decimal let kind: Kind }
struct Payee: Codable, Identifiable, Hashable { let id: String var name: String var bank: String var accountNumber: String }
struct GoalJar: Codable, Identifiable, Hashable { let id: String var title: String var target: Decimal var saved: Decimal }
// MARK: - Services (Mock) actor KeychainService { private var storage: [String: Data] = [:] // Replace with Keychain in production func set(_ data: Data, for key: String) { storage[key] = data } func get(_ key: String) -> Data? { storage[key] } func delete(_ key: String) { storage[key] = nil } }
actor ApiService { // Simulate network with delay func requestOTP(phone: String) async throws { try await Task.sleep(nanoseconds: 400_000_000) } func verifyOTP(code: String) async throws -> User { try await Task.sleep(nanoseconds: 400_000_000) return MockData.user } func fetchAccounts() async throws -> [Account] { try await Task.sleep(nanoseconds: 250_000_000) return MockData.accounts } func fetchTransactions(accountId: String) async throws -> [Txn] { try await Task.sleep(nanoseconds: 250_000_000) return MockData.transactions.filter{ $0.accountId == accountId } } func transfer(to payee: Payee, amount: Decimal) async throws -> String { try await Task.sleep(nanoseconds: 500_000_000) return "TRX-(Int.random(in: 100000...999999))" } }
// MARK: - Auth @mainactor final class AuthViewModel: ObservableObject { enum Phase { case loggedOut, otpPending, authenticated } @published var phone = "" @published var phase: Phase = .loggedOut @published var isLoading = false @published var error: String? @published var user: User?
private let api = ApiService()
private let keychain = KeychainService()
func requestOTP() {
Task { await _requestOTP() }
}
private func _requestOTP() async {
guard phone.starts(with: "+92") else { error = "Enter phone in +92 format"; return }
error = nil; isLoading = true
do { try await api.requestOTP(phone: phone); phase = .otpPending }
catch { self.error = "Failed to send OTP" }
isLoading = false
}
func verifyOTP(_ code: String) {
Task { await _verifyOTP(code) }
}
private func verifyOTP( code: String) async {
isLoading = true; error = nil
do {
let u = try await api.verifyOTP(code: code)
user = u; phase = .authenticated
try? await keychain.set(Data("session".utf8), for: "AB.session")
} catch {
self.error = "Invalid code"
}
isLoading = false
}
func logout() {
Task { await keychain.delete("AB.session") }
phase = .loggedOut; user = nil; phone = ""
}
func biometricLoginIfPossible() {
let context = LAContext()
var err: NSError?
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &err) {
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "Login to Anwar Bank") { success, _ in
DispatchQueue.main.async {
if success { self.phase = .authenticated; self.user = MockData.user }
}
}
}
}
}
// MARK: - Dashboard VM @mainactor final class DashboardViewModel: ObservableObject { @published var accounts: [Account] = [] @published var selected: Account? @published var txns: [Txn] = [] @published var isLoading = false
private let api = ApiService()
func load() {
Task { await _load() }
}
private func _load() async {
isLoading = true
do {
accounts = try await api.fetchAccounts()
if selected == nil { selected = accounts.first }
if let id = selected?.id { txns = try await api.fetchTransactions(accountId: id) }
} catch {
// handle error UI as needed
}
isLoading = false
}
func refreshTxns() {
Task { @mainactor in
if let id = selected?.id { txns = (try? await api.fetchTransactions(accountId: id)) ?? [] }
}
}
}
// MARK: - Transfer VM @mainactor final class TransferViewModel: ObservableObject { @published var payees: [Payee] = MockData.payees @published var selected: Payee? = nil @published var amount: String = "" @published var note: String = "" @published var lastRef: String? = nil @published var isLoading = false @published var error: String? = nil
private let api = ApiService()
func send() {
guard let payee = selected, let amt = Decimal(string: amount), amt > 0 else {
error = "Select payee and enter valid amount"; return
}
error = nil; isLoading = true
Task {
do { let ref = try await api.transfer(to: payee, amount: Decimal(string: amount) ?? 0); lastRef = ref }
catch { error = "Transfer failed" }
isLoading = false
}
}
}
// MARK: - Goals VM (Savings Jars) @mainactor final class GoalsViewModel: ObservableObject { @published var jars: [GoalJar] = MockData.jars func add(title: String, target: Decimal) { jars.append(.init(id: UUID().uuidString, title: title, target: target, saved: 0)) } func deposit(id: String, amount: Decimal) { if let i = jars.firstIndex(where: { $0.id == id }) { jars[i].saved += amount } } }
// MARK: - Views struct LoginView: View { @EnvironmentObject var auth: AuthViewModel @State private var useBiometric = false
var body: some View {
NavigationStack {
VStack(spacing: 24) {
VStack(spacing: 8) {
Image(systemName: "creditcard")
.font(.system(size: 48, weight: .semibold))
.padding(12)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 24))
Text("Anwar Bank")
.font(.largeTitle).fontWeight(.bold)
Text("Pakistan • Secure • Simple")
.foregroundStyle(.secondary)
}
.padding(.top, 40)
VStack(alignment: .leading, spacing: 8) {
Text("Phone (+92)").font(.caption).foregroundStyle(.secondary)
TextField("+92XXXXXXXXXX", text: $auth.phone)
.keyboardType(.phonePad)
.textContentType(.telephoneNumber)
.padding().background(Color.abBg, in: RoundedRectangle(cornerRadius: 16))
}
Toggle("Enable Face ID / Touch ID", isOn: $useBiometric)
.toggleStyle(.switch)
Button(action: auth.requestOTP) {
HStack { if auth.isLoading { ProgressView() } ; Text("Get OTP") }
}
.disabled(auth.isLoading)
.buttonStyle(.borderedProminent)
.padding(.top, 8)
if let e = auth.error { Text(e).foregroundStyle(.red) }
Spacer()
Button("Try Biometric Login") { auth.biometricLoginIfPossible() }
.buttonStyle(.bordered)
}
.padding()
}
}
}
struct OTPView: View { @EnvironmentObject var auth: AuthViewModel @State private var code = "" var body: some View { VStack(spacing: 20) { Text("Enter 6-digit OTP").font(.title3).bold() TextField("123456", text: $code) .keyboardType(.numberPad) .multilineTextAlignment(.center) .padding().background(Color.abBg, in: RoundedRectangle(cornerRadius: 16)) Button(action: { auth.verifyOTP(code) }) { HStack { if auth.isLoading { ProgressView() } ; Text("Verify & Continue") } } .buttonStyle(.borderedProminent) if let e = auth.error { Text(e).foregroundStyle(.red) } } .padding() } }
struct MainTabView: View { var body: some View { TabView { DashboardView() .tabItem { Label("Home", systemImage: "house.fill") } TransferView() .tabItem { Label("Transfer", systemImage: "arrow.left.arrow.right") } GoalsView() .tabItem { Label("Goals", systemImage: "target") } QRPayView() .tabItem { Label("QR", systemImage: "qrcode") } MoreView() .tabItem { Label("More", systemImage: "ellipsis.circle") } } .accentColor(.abPrimary) } }
struct DashboardView: View { @StateObject private var vm = DashboardViewModel()
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if vm.isLoading { ProgressView().frame(maxWidth: .infinity) }
// Accounts horizontal cards
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(vm.accounts) { acct in
AccountCard(account: acct, selected: vm.selected?.id == acct.id)
.onTapGesture { vm.selected = acct; vm.refreshTxns() }
}
}.padding(.horizontal)
}
// Recent transactions
VStack(alignment: .leading, spacing: 8) {
HStack { Text("Recent").font(.headline); Spacer(); Button("See all"){} }
if let acct = vm.selected {
ForEach(vm.txns.prefix(8)) { t in TransactionRow(txn: t) }
if vm.txns.isEmpty { Text("No transactions yet").foregroundStyle(.secondary) }
}
}.padding(.horizontal)
}
.padding(.vertical)
}
.navigationTitle("Anwar Bank")
.toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: vm.load) { Image(systemName: "arrow.clockwise") } } }
}
.task { vm.load() }
}
}
struct AccountCard: View { let account: Account let selected: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { Text(account.type == .current ? "Current" : account.type == .savings ? "Savings" : "Islamic Savings") .font(.caption).foregroundStyle(.secondary) Text(formatCurrency(account.balancePKR)) .font(.title2).bold() Text(maskIBAN(account.iban)).font(.caption2).foregroundStyle(.secondary) } .padding(16) .frame(width: 220, alignment: .leading) .background(selected ? .blue.opacity(0.15) : .thinMaterial, in: RoundedRectangle(cornerRadius: 24)) .overlay(alignment: .topTrailing) { Image(systemName: "banknote") .padding(8) } .shadow(radius: selected ? 4 : 0) } }
struct TransactionRow: View { let txn: Txn var body: some View { HStack { Image(systemName: txn.kind == .debit ? "arrow.up.right.circle" : "arrow.down.left.circle") .font(.title3) VStack(alignment: .leading) { Text(txn.description).font(.subheadline) Text(txn.date, style: .date).font(.caption).foregroundStyle(.secondary) } Spacer() Text(formatCurrency(txn.amountPKR * (txn.kind == .debit ? -1 : 1))) .font(.subheadline).bold() } .padding(12) .background(Color.abBg, in: RoundedRectangle(cornerRadius: 16)) } }
struct TransferView: View { @StateObject private var vm = TransferViewModel()
var body: some View {
NavigationStack {
Form {
Section("Payee") {
Picker("Select", selection: $vm.selected) {
Text("Choose...").tag(Payee?.none)
ForEach(vm.payees) { p in Text("(p.name) – (p.bank)").tag(Payee?.some(p)) }
}
}
Section("Amount") {
TextField("PKR", text: $vm.amount).keyboardType(.decimalPad)
TextField("Note (optional)", text: $vm.note)
}
if let ref = vm.lastRef { Section { Label("Sent: (ref)", systemImage: "checkmark.seal") } }
if let e = vm.error { Section { Text(e).foregroundStyle(.red) } }
}
.navigationTitle("Transfer")
.toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Send") { vm.send() } .disabled(vm.isLoading) } }
}
}
}
struct GoalsView: View { @StateObject private var vm = GoalsViewModel() @State private var showAdd = false @State private var newTitle = "" @State private var newTarget = ""
var body: some View {
NavigationStack {
List {
ForEach(vm.jars) { jar in
VStack(alignment: .leading, spacing: 8) {
HStack { Text(jar.title).bold(); Spacer(); Text(progressLabel(jar)) }
ProgressView(value: min(1, (NSDecimalNumber(decimal: jar.saved).doubleValue / max(1, NSDecimalNumber(decimal: jar.target).doubleValue))))
HStack {
Button("+1,000") { vm.deposit(id: jar.id, amount: 1000) }.buttonStyle(.bordered)
Button("+5,000") { vm.deposit(id: jar.id, amount: 5000) }.buttonStyle(.bordered)
}
}
.padding(.vertical, 6)
}
}
.navigationTitle("Savings Jars")
.toolbar { ToolbarItem(placement: .topBarTrailing) { Button(action: { showAdd = true }) { Image(systemName: "plus.circle.fill") } } }
.sheet(isPresented: $showAdd) {
NavigationStack {
Form {
TextField("Title (e.g., Umrah)", text: $newTitle)
TextField("Target (PKR)", text: $newTarget).keyboardType(.decimalPad)
}.navigationTitle("New Jar").toolbar { ToolbarItem(placement: .confirmationAction) { Button("Add") { if let t = Decimal(string: newTarget), !newTitle.isEmpty { vm.add(title: newTitle, target: t) ; showAdd = false; newTitle = ""; newTarget = "" } } } }
}
}
}
}
private func progressLabel(_ jar: GoalJar) -> String { "(formatCurrency(jar.saved)) / (formatCurrency(jar.target))" }
}
struct QRPayView: View { @State private var amount = "" @State private var reference = UUID().uuidString.prefix(8) @State private var isReceiving = false var body: some View { NavigationStack { VStack(spacing: 20) { Text("Receive via QR (Offline Mock)").font(.headline) TextField("Amount (PKR)", text: $amount).keyboardType(.decimalPad) .padding().background(Color.abBg, in: RoundedRectangle(cornerRadius: 16)) Image(systemName: "qrcode").resizable().scaledToFit().frame(height: 160) Text("Ref: (reference)").foregroundStyle(.secondary) Toggle("I am ready to receive", isOn: $isReceiving) Spacer() } .padding() .navigationTitle("QR Pay") } } }
struct MoreView: View { var body: some View { NavigationStack { List { NavigationLink(destination: BillsView()) { Label("Pay Bills (Stub)", systemImage: "doc.text") } NavigationLink(destination: CardManagementView()) { Label("Cards", systemImage: "creditcard") } NavigationLink(destination: SupportChatView()) { Label("Support Chat (Mock)", systemImage: "bubble.left.and.bubble.right") } NavigationLink(destination: ProfileView()) { Label("Profile", systemImage: "person.circle") } NavigationLink(destination: SettingsView()) { Label("Settings", systemImage: "gear") } } .navigationTitle("More") } } }
struct BillsView: View { var body: some View { Text("Billers and utilities integration TBD").padding() } }
struct CardManagementView: View { @State private var isLocked = false var body: some View { Form { Section("Virtual Card") { HStack { Text("**** **** **** 4242"); Spacer(); Text("PKR"); } Toggle("Lock card", isOn: $isLocked) Button("Set spending limit") {} } } .navigationTitle("Cards") } }
struct SupportChatView: View { @State private var messages: [String] = ["Assalam o Alaikum! How can we help?" ] @State private var input = "" var body: some View { VStack { List(messages, id: .self) { Text($0) } HStack { TextField("Type message", text: $input) Button("Send") { if !input.isEmpty { messages.append(input); input = "" } } }.padding().background(.thinMaterial) } .navigationTitle("Support Chat") } }
struct ProfileView: View { @EnvironmentObject var auth: AuthViewModel var body: some View { Form { if let u = auth.user { Section("Account") { LabeledContent("Name", value: u.fullName) LabeledContent("Phone", value: u.phone) LabeledContent("CNIC", value: maskCNIC(u.cnic)) } } Section { Button("Logout", role: .destructive) { auth.logout() } } } .navigationTitle("Profile") } }
struct SettingsView: View { @State private var lang = 0 // 0=en, 1=ur (placeholder) @State private var themeDark = false @State private var zakatAmount = "" var body: some View { Form { Picker("Language", selection: $lang) { Text("English").tag(0); Text("اردو").tag(1) } Toggle("Dark Mode", isOn: $themeDark) Section("Zakat Calculator (2.5%)") { TextField("Wealth (PKR)", text: $zakatAmount).keyboardType(.decimalPad) if let n = Decimal(string: zakatAmount), n > 0 { Text("Zakat due: (formatCurrency(n * 0.025))") } } } .navigationTitle("Settings") } }
// MARK: - Utilities & Mock func formatCurrency(_ amount: Decimal) -> String { let ns = NSDecimalNumber(decimal: amount) let f = NumberFormatter(); f.numberStyle = .currency; f.currencyCode = "PKR"; f.maximumFractionDigits = 0 return f.string(from: ns) ?? "PKR 0" }
func maskIBAN(_ iban: String) -> String { guard iban.count > 6 else { return iban }; return String(iban.prefix(6)) + String(repeating: "*", count: max(0, iban.count - 10)) + String(iban.suffix(4)) } func maskCNIC(_ c: String) -> String { // 13 digits -> 12345-1234567-1 let digits = c.filter(.$0.isNumber) guard digits.count == 13 else { return c } let a = digits.prefix(5) let b = digits.dropFirst(5).prefix(7) let d = digits.suffix(1) return "(a)-(b)-(d)" }
enum MockData { static let user = User(id: "u1", fullName: "Anwar Ali", phone: "+923001400025", cnic: "4210112345671") static let accounts: [Account] = [ .init(id: "a1", type: .current, iban: "PK98ANWR0000123456789012", balancePKR: 125000), .init(id: "a2", type: .islamicSavings, iban: "PK12ANWR0000098765432101", balancePKR: 830000) ] static let transactions: [Txn] = { var ds: [Txn] = [] let descs = ["Grocery Store", "Fuel", "Salary", "Electric Bill", "Mobile Top-up", "School Fee", "Zakat", "Remittance"] for i in 0..<18 { let acct = i % 2 == 0 ? "a1" : "a2" let kind: Txn.Kind = i % 3 == 0 ? .credit : .debit let amt: Decimal = kind == .credit ? Decimal(Int.random(in: 20000...150000)) : Decimal(Int.random(in: 500...15000)) ds.append(Txn(id: UUID().uuidString, accountId: acct, date: Date().addingTimeInterval(Double(-i*86400)), description: descs[i % descs.count], amountPKR: amt, kind: kind)) } return ds }() static let payees: [Payee] = [ .init(id: