Implement bank transfer app flows
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContactsView: View {
|
||||
@Environment(HomeViewModel.self) private var homeVM
|
||||
@State private var searchText = ""
|
||||
|
||||
private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
|
||||
|
||||
private var filtered: [BankContact] {
|
||||
homeVM.contacts(matching: searchText)
|
||||
}
|
||||
|
||||
private var grouped: [(String, [BankContact])] {
|
||||
let order = ["MIB", "BML", "FAHIPAY"]
|
||||
return order.compactMap { source in
|
||||
let group = filtered.filter { $0.source == source }
|
||||
return group.isEmpty ? nil : (source, group)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if homeVM.isLoadingContacts && homeVM.contacts.isEmpty {
|
||||
loadingView
|
||||
} else if filtered.isEmpty && homeVM.contactsError == nil && !homeVM.isLoadingContacts {
|
||||
emptyView
|
||||
} else {
|
||||
contactsList
|
||||
}
|
||||
}
|
||||
.navigationTitle("Contacts")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.searchable(text: $searchText, prompt: "Search name or account")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if homeVM.isLoadingContacts {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button {
|
||||
Task { await homeVM.fetchContacts() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await homeVM.fetchContactsIfNeeded()
|
||||
}
|
||||
.refreshable {
|
||||
await homeVM.fetchContacts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contact list
|
||||
|
||||
private var contactsList: some View {
|
||||
List {
|
||||
if let err = homeVM.contactsError {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Fetch error", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.orange)
|
||||
Text(err)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
ForEach(grouped, id: \.0) { source, contacts in
|
||||
Section(header: sectionHeader(source)) {
|
||||
ForEach(contacts) { contact in
|
||||
ContactRow(contact: contact) {
|
||||
homeVM.pendingTransferContact = contact
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
// MARK: - Section header
|
||||
|
||||
private func sectionHeader(_ source: String) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(bankColor(source))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(bankFullName(source))
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(bankColor(source))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty / loading
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "person.badge.plus")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(searchText.isEmpty ? "No saved contacts" : "No results for \"\(searchText)\"")
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
if searchText.isEmpty {
|
||||
Text("After a successful transfer, tap \"Save Contact\" on the receipt to add the recipient here.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading contacts…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func bankColor(_ source: String) -> Color {
|
||||
switch source {
|
||||
case "MIB": return Color(red: 0.247, green: 0.396, blue: 0.678)
|
||||
case "BML": return .blue
|
||||
case "FAHIPAY": return .purple
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private func bankFullName(_ source: String) -> String {
|
||||
switch source {
|
||||
case "MIB": return "Maldives Islamic Bank"
|
||||
case "BML": return "Bank of Maldives"
|
||||
case "FAHIPAY": return "Fahipay"
|
||||
default: return source
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contact row
|
||||
|
||||
struct ContactRow: View {
|
||||
let contact: BankContact
|
||||
var onSend: () -> Void
|
||||
|
||||
private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
avatarCircle
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(contact.displayName)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Text(contact.benefAccount)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
if let bank = contact.benefBankName, bank != "Maldives Islamic Bank", bank != "Bank of Maldives" {
|
||||
Text("·")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(bank)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
typeBadge
|
||||
}
|
||||
Spacer()
|
||||
Button(action: onSend) {
|
||||
Image(systemName: "paperplane.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(primary)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var avatarCircle: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(avatarColor.opacity(0.15))
|
||||
.frame(width: 40, height: 40)
|
||||
Text(initials)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(avatarColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var typeBadge: some View {
|
||||
Text(typeLabel)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(typeColor.opacity(0.12))
|
||||
.foregroundStyle(typeColor)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
let words = contact.displayName.split(separator: " ").prefix(2)
|
||||
return words.compactMap { $0.first }.map(String.init).joined()
|
||||
}
|
||||
|
||||
private var avatarColor: Color {
|
||||
switch contact.source {
|
||||
case "MIB": return Color(red: 0.247, green: 0.396, blue: 0.678)
|
||||
case "BML": return .blue
|
||||
case "FAHIPAY": return .purple
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private var typeLabel: String {
|
||||
switch contact.benefType {
|
||||
case "MIB": return "MIB"
|
||||
case "LOCAL": return "IPS"
|
||||
case "SWIFT": return "SWIFT"
|
||||
case "BML": return "BML"
|
||||
default: return contact.benefType
|
||||
}
|
||||
}
|
||||
|
||||
private var typeColor: Color {
|
||||
switch contact.benefType {
|
||||
case "SWIFT": return .orange
|
||||
case "LOCAL": return .green
|
||||
default: return primary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContactsView()
|
||||
.environment(HomeViewModel())
|
||||
}
|
||||
Reference in New Issue
Block a user