Files
modeazimandClaude Sonnet 4.6 2bbb280380 Add Tap to Pay feature and misc fixes
Adds BML contactless token fetching, APDU emulation utility, TapToPayViewModel, and TapToPayView with NFC animation. Also updates QR scanner, transfer flow, dashboard, and home views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:27:27 +05:00

877 lines
33 KiB
Swift

import SwiftUI
struct TransferView: View {
private enum FocusedField {
case toAccount
case amount
case remarks
}
@Environment(HomeViewModel.self) private var homeVM
@State private var vm = TransferViewModel()
@State private var showAccountPicker = false
@State private var showContactPicker = false
@State private var showBmlOtp = false
@State private var showQRScanner = false
@State private var accountSearchText = ""
@State private var contactSearchText = ""
@State private var toInputText = ""
@State private var amountInputText = ""
@State private var purposeInputText = ""
@State private var completedReceipt: ReceiptPresentation? = nil
@State private var pendingReceiptAfterOtp: TransferReceiptData? = nil
@State private var isProgrammaticToInputChange = false
@FocusState private var focusedField: FocusedField?
private let primary = Color(red: 0.247, green: 0.396, blue: 0.678)
private struct ReceiptPresentation: Identifiable {
let id = UUID()
let receipt: TransferReceiptData
}
// Accounts eligible to send from
private var sendableAccounts: [BankAccount] {
homeVM.accounts.filter {
($0.bank == "MIB" || $0.bank == "BML") &&
!["BML_CREDIT", "BML_DEBIT", "BML_PREPAID", "BML_LOAN", "MIB_CARD"].contains($0.profileType) &&
$0.isActive
}
}
private var filteredSendableAccounts: [BankAccount] {
guard !accountSearchText.isEmpty else { return sendableAccounts }
let query = accountSearchText.lowercased()
return sendableAccounts.filter {
$0.bank.lowercased().contains(query) ||
$0.accountNumber.contains(query) ||
$0.accountBriefName.lowercased().contains(query) ||
$0.accountTypeName.lowercased().contains(query)
}
}
private var destinationAccounts: [BankAccount] {
homeVM.accounts.filter {
($0.bank == "MIB" || $0.bank == "BML") &&
$0.profileType != "BML_LOAN" &&
$0.id != vm.fromAccount?.id
}
}
private var filteredDestinationAccounts: [BankAccount] {
guard !contactSearchText.isEmpty else { return destinationAccounts }
let query = contactSearchText.lowercased()
return destinationAccounts.filter {
$0.bank.lowercased().contains(query) ||
$0.accountNumber.contains(query) ||
$0.accountBriefName.lowercased().contains(query) ||
$0.accountTypeName.lowercased().contains(query)
}
}
private var filteredContacts: [BankContact] {
guard !contactSearchText.isEmpty else { return homeVM.contacts }
let query = contactSearchText.lowercased()
return homeVM.contacts.filter {
$0.displayName.lowercased().contains(query) ||
$0.benefAccount.contains(query) ||
($0.benefBankName?.lowercased().contains(query) ?? false) ||
($0.benefCategoryId?.lowercased().contains(query) ?? false)
}
}
private var canSubmitTransfer: Bool {
guard let from = vm.fromAccount else { return false }
let amount = Double(amountInputText.replacingOccurrences(of: ",", with: "")) ?? 0
guard amount > 0 else { return false }
switch vm.lookupState {
case .found, .bmlReady:
return true
default:
return from.bank == "BML" && !toInputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
private var groupedContacts: [(String, [BankContact])] {
let groups = Dictionary(grouping: filteredContacts) { contactCategoryTitle($0) }
return groups.keys.sorted().compactMap { key in
guard let contacts = groups[key], !contacts.isEmpty else { return nil }
return (key, contacts.sorted { $0.displayName < $1.displayName })
}
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 16) {
fromAccountCard
toAccountCard
amountCard
remarksCard
transferButton
}
.padding(16)
}
.scrollDismissesKeyboard(.interactively)
.background(Color(.systemGroupedBackground))
.contentShape(Rectangle())
.onTapGesture { focusedField = nil }
.navigationTitle("Transfer")
.navigationBarTitleDisplayMode(.inline)
}
.sheet(isPresented: $showAccountPicker) {
accountPickerSheet
.onDisappear { accountSearchText = "" }
}
.sheet(isPresented: $showBmlOtp, onDismiss: presentPendingReceiptAfterOtp) {
bmlOtpSheet
}
.sheet(isPresented: $showContactPicker) {
contactPickerSheet
.onDisappear { contactSearchText = "" }
}
.sheet(item: $completedReceipt, onDismiss: finishCompletedTransfer) { presentation in
TransferReceiptView(
receipt: presentation.receipt,
onSaveContact: { contact in
homeVM.saveContact(contact)
}
) {
completedReceipt = nil
finishCompletedTransfer()
}
}
.sheet(isPresented: $showQRScanner) {
QRScannerSheet { qrContent in
handleQR(qrContent)
}
}
.onChange(of: vm.transferState) { _, state in
switch state {
case .success(let receipt):
focusedField = nil
if showBmlOtp {
pendingReceiptAfterOtp = receipt
showBmlOtp = false
} else {
completedReceipt = ReceiptPresentation(receipt: receipt)
}
case .bmlStep2:
focusedField = nil
showBmlOtp = true
default:
break
}
}
.onChange(of: homeVM.pendingTransferContact) { _, contact in
guard let contact else { return }
selectContact(contact)
homeVM.pendingTransferContact = nil
}
.onChange(of: homeVM.pendingTransferQRData) { _, qrData in
guard let qrData else { return }
handleQRFromDashboard(qrData)
homeVM.pendingTransferQRData = nil
}
.onChange(of: homeVM.pendingBmlQrUrl) { _, url in
guard let url else { return }
handleBmlQrFromDashboard(url)
homeVM.pendingBmlQrUrl = nil
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") { focusedField = nil }
}
}
}
// MARK: - From account card
private var fromAccountCard: some View {
Button { showAccountPicker = true } label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("From Account")
.font(.caption)
.foregroundStyle(.secondary)
if let acc = vm.fromAccount {
Text(acc.accountBriefName.isEmpty ? acc.accountTypeName : acc.accountBriefName)
.font(.subheadline.bold())
Text(acc.accountNumber)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Text(acc.formattedAvailableBalance)
.font(.caption)
.foregroundStyle(primary)
} else {
Text("Select account")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.down")
.foregroundStyle(.secondary)
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
// MARK: - To account card
private var toAccountCard: some View {
VStack(alignment: .leading, spacing: 10) {
Text("To Account")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
// Text field with lookup icon inside its border
HStack(spacing: 8) {
TextField("Account Number or Favara ID", text: $toInputText)
.focused($focusedField, equals: .toAccount)
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.next)
.onSubmit { focusedField = .amount }
.font(.subheadline)
.onChange(of: toInputText) { _, _ in
if isProgrammaticToInputChange {
isProgrammaticToInputChange = false
return
}
vm.lookupState = .idle
vm.bmlTrnType = "IAT"
vm.bmlBank = nil
vm.bmlCreditAccountOverride = nil
}
if vm.fromAccount?.bank == "MIB" || vm.fromAccount?.bank == "BML" {
lookupButton
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(.separator), lineWidth: 0.5)
)
// Contact picker
Button { showContactPicker = true } label: {
Image(systemName: "person.fill")
.font(.subheadline)
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(Color.green)
.clipShape(Circle())
}
.buttonStyle(.plain)
// QR scanner
Button { showQRScanner = true } label: {
Image(systemName: "qrcode.viewfinder")
.font(.subheadline)
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(Color.green)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
lookupResultRow
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@ViewBuilder
private var lookupButton: some View {
switch vm.lookupState {
case .loading:
ProgressView()
.controlSize(.small)
default:
Button {
performBeneficiaryLookup()
} label: {
Image(systemName: "magnifyingglass")
.font(.subheadline)
.foregroundStyle(toInputText.isEmpty ? Color.secondary : primary)
}
.disabled(toInputText.isEmpty)
}
}
@ViewBuilder
private var lookupResultRow: some View {
switch vm.lookupState {
case .found(let r):
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text(r.accountName).font(.caption.bold())
Text(r.bankName).font(.caption2).foregroundStyle(.secondary)
}
}
case .bmlReady(let name) where !name.isEmpty:
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
Text(name).font(.caption.bold())
}
case .error(let msg):
HStack(spacing: 6) {
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
Text(msg).font(.caption).foregroundStyle(.red)
}
default:
EmptyView()
}
}
// MARK: - Amount card
private var amountCard: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Amount")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
.padding(.top, 12)
HStack {
Text(vm.fromAccount?.currencyName ?? "MVR")
.font(.subheadline.bold())
.foregroundStyle(primary)
.frame(width: 44)
TextField("0.00", text: $amountInputText)
.focused($focusedField, equals: .amount)
.keyboardType(.decimalPad)
.submitLabel(.next)
.onSubmit { focusedField = .remarks }
.font(.title3.bold())
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Remarks card
private var remarksCard: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Remarks")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
.padding(.top, 12)
TextField("Fund Transfer", text: $purposeInputText)
.focused($focusedField, equals: .remarks)
.submitLabel(.done)
.onSubmit { focusedField = nil }
.font(.subheadline)
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Transfer button
@ViewBuilder
private var transferButton: some View {
if case .processing = vm.transferState {
ProgressView("Processing transfer…")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
} else {
VStack(spacing: 8) {
if case .failure(let msg) = vm.transferState {
Text(msg)
.font(.caption)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
}
Button {
syncFormToViewModel()
focusedField = nil
Task { await vm.submitTransfer() }
} label: {
Text("Transfer")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(canSubmitTransfer ? primary : Color.gray.opacity(0.4))
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(!canSubmitTransfer)
}
}
}
// MARK: - Account picker sheet
private var accountPickerSheet: some View {
NavigationStack {
List(filteredSendableAccounts) { acc in
Button {
vm.fromAccount = acc
vm.lookupState = .idle
vm.bmlTrnType = "IAT"
vm.bmlBank = nil
vm.bmlCreditAccountOverride = nil
showAccountPicker = false
} label: {
HStack {
bankDot(acc.bank)
VStack(alignment: .leading, spacing: 3) {
Text(acc.accountBriefName.isEmpty ? acc.accountTypeName : acc.accountBriefName)
.font(.subheadline.bold())
.foregroundStyle(.primary)
Text(acc.accountNumber)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Text(acc.formattedAvailableBalance)
.font(.caption.bold())
.foregroundStyle(primary)
}
}
}
.navigationTitle("From Account")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $accountSearchText, prompt: "Search account")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") { showAccountPicker = false }
}
}
}
}
// MARK: - BML OTP sheet
private var bmlOtpSheet: some View {
NavigationStack {
VStack(spacing: 24) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 48))
.foregroundStyle(primary)
Text("Confirm Transfer")
.font(.title2.bold())
Text("The OTP will be generated automatically from your stored authenticator seed.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if case .processing = vm.transferState {
ProgressView("Confirming…")
} else {
Button {
guard let pid = vm.fromAccount?.profileId
?? vm.fromAccount.flatMap({ CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles($0.loginTag.replacingOccurrences(of: "bml_", with: ""))).first }),
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlOtpSeed(
vm.fromAccount?.loginTag.replacingOccurrences(of: "bml_", with: "") ?? ""
)) else { return }
let _ = pid // suppress unused warning
Task { await vm.confirmBmlOtp(otpSeed: seed) }
} label: {
Text("Confirm with Authenticator")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(primary)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal, 24)
if case .failure(let msg) = vm.transferState {
Text(msg)
.font(.caption)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
.navigationTitle("OTP Confirmation")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
vm.transferState = .idle
showBmlOtp = false
}
}
}
}
.presentationDetents([.medium])
.onChange(of: vm.transferState) { _, state in
if case .success = state {
showBmlOtp = false
}
}
}
// MARK: - Contact picker sheet
private var contactPickerSheet: some View {
NavigationStack {
List {
if !filteredDestinationAccounts.isEmpty {
Section("My Accounts") {
ForEach(filteredDestinationAccounts) { account in
Button {
selectDestinationAccount(account)
showContactPicker = false
} label: {
destinationAccountRow(account)
}
.buttonStyle(.plain)
}
}
}
ForEach(groupedContacts, id: \.0) { title, contacts in
Section(title) {
ForEach(contacts) { contact in
Button {
selectContact(contact)
showContactPicker = false
} label: {
contactPickerRow(contact)
}
.buttonStyle(.plain)
}
}
}
}
.navigationTitle("Select Contact")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $contactSearchText, prompt: "Search contacts")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") { showContactPicker = false }
}
}
}
}
// MARK: - Transfer completion
private func finishCompletedTransfer() {
completedReceipt = nil
pendingReceiptAfterOtp = nil
focusedField = nil
showBmlOtp = false
vm.reset()
toInputText = ""
amountInputText = ""
purposeInputText = ""
}
private func presentPendingReceiptAfterOtp() {
guard let receipt = pendingReceiptAfterOtp else { return }
pendingReceiptAfterOtp = nil
completedReceipt = ReceiptPresentation(receipt: receipt)
}
private func syncFormToViewModel() {
vm.toInput = toInputText
vm.amountInput = amountInputText
vm.purposeInput = purposeInputText
}
private func setToInput(_ value: String) {
guard toInputText != value else {
vm.toInput = value
isProgrammaticToInputChange = false
return
}
isProgrammaticToInputChange = true
toInputText = value
vm.toInput = value
}
// MARK: - QR handler
private func performBeneficiaryLookup() {
syncFormToViewModel()
if resolveCachedRecipient() { return }
Task { await vm.lookupBeneficiary() }
}
private func resolveCachedRecipient() -> Bool {
let query = toInputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return false }
if let account = destinationAccounts.first(where: { $0.accountNumber == query }) {
selectDestinationAccount(account)
return true
}
if let contact = homeVM.contacts.first(where: {
$0.benefAccount == query ||
$0.benefNo == query ||
$0.displayName.caseInsensitiveCompare(query) == .orderedSame
}) {
selectContact(contact)
return true
}
return false
}
private func handleQR(_ content: String) {
if let result = PaymvQrParser.parse(content) {
setToInput(result.account)
vm.lookupState = .idle
if let amount = result.amount, !amount.isEmpty {
amountInputText = amount
vm.amountInput = amount
}
if let purpose = result.purpose, !purpose.isEmpty {
purposeInputText = purpose
vm.purposeInput = purpose
} else if let name = result.merchantName, !name.isEmpty {
purposeInputText = "Pay \(name)"
vm.purposeInput = purposeInputText
}
// Trigger lookup for the filled account
if vm.fromAccount?.bank == "MIB" {
Task { await vm.lookupBeneficiary() }
} else if vm.fromAccount?.bank == "BML" {
Task { await vm.lookupBeneficiary() }
}
} else {
// Fallback: treat raw content as an account number
setToInput(content)
vm.lookupState = .idle
}
}
private func handleQRFromDashboard(_ result: PaymvQrResult) {
setToInput(result.account)
vm.lookupState = .idle
if let amount = result.amount, !amount.isEmpty {
amountInputText = amount
vm.amountInput = amount
}
if let purpose = result.purpose, !purpose.isEmpty {
purposeInputText = purpose
vm.purposeInput = purpose
} else if let name = result.merchantName, !name.isEmpty {
purposeInputText = "Pay \(name)"
vm.purposeInput = purposeInputText
}
if let cardNumber = homeVM.pendingQrFromCard {
if let cardAccount = homeVM.accounts.first(where: {
$0.accountNumber == cardNumber &&
($0.profileType == "BML_PREPAID" || $0.profileType == "BML_CREDIT" || $0.profileType == "BML_DEBIT")
}) {
vm.fromAccount = cardAccount
}
homeVM.pendingQrFromCard = nil
}
if vm.fromAccount?.bank == "MIB" {
Task { await vm.lookupBeneficiary() }
} else if vm.fromAccount?.bank == "BML" {
Task { await vm.lookupBeneficiary() }
}
}
private func handleBmlQrFromDashboard(_ url: String) {
setToInput(url)
vm.lookupState = .idle
if let cardNumber = homeVM.pendingQrFromCard {
if let cardAccount = homeVM.accounts.first(where: {
$0.accountNumber == cardNumber &&
($0.profileType == "BML_PREPAID" || $0.profileType == "BML_CREDIT" || $0.profileType == "BML_DEBIT")
}) {
vm.fromAccount = cardAccount
}
homeVM.pendingQrFromCard = nil
}
}
private func destinationAccountRow(_ account: BankAccount) -> some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(contactColor(account.bank).opacity(0.15))
.frame(width: 36, height: 36)
Text(accountInitials(account))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(contactColor(account.bank))
}
VStack(alignment: .leading, spacing: 3) {
Text(account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName)
.font(.subheadline.bold())
.foregroundStyle(.primary)
Text("\(account.bank) · \(account.accountNumber)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Text(account.formattedAvailableBalance)
.font(.caption.bold())
.foregroundStyle(primary)
}
}
private func contactPickerRow(_ contact: BankContact) -> some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(contactColor(contact.source).opacity(0.15))
.frame(width: 36, height: 36)
Text(contactInitials(contact.displayName))
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(contactColor(contact.source))
}
VStack(alignment: .leading, spacing: 3) {
Text(contact.displayName)
.font(.subheadline.bold())
.foregroundStyle(.primary)
Text(contactSubtitle(contact))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Text(contact.source)
.font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(contactColor(contact.source).opacity(0.12), in: Capsule())
.foregroundStyle(contactColor(contact.source))
}
}
private func selectDestinationAccount(_ account: BankAccount) {
setToInput(account.accountNumber)
guard let from = vm.fromAccount else { return }
if from.bank == "MIB" {
let isMibInternal = account.bank == "MIB"
vm.lookupState = .found(MibAccountLookupResult(
accountNumber: account.accountNumber,
accountName: account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName,
bankName: isMibInternal ? "Maldives Islamic Bank" : "Bank of Maldives",
bankCode: isMibInternal ? "2" : "3",
aliasId: nil,
network: isMibInternal ? .mibInternal : .local
))
} else if from.bank == "BML" {
if account.bank == "MIB" {
vm.bmlTrnType = "DOT"
vm.bmlBank = "MIB"
vm.bmlCreditAccountOverride = nil
} else if isBmlCard(account) {
vm.bmlTrnType = "CPA"
vm.bmlBank = nil
vm.bmlCreditAccountOverride = (account.internalId ?? "").isEmpty ? account.accountNumber : account.internalId
} else {
vm.bmlTrnType = "IAT"
vm.bmlBank = nil
vm.bmlCreditAccountOverride = nil
}
vm.lookupState = .bmlReady(account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName)
}
}
private func selectContact(_ contact: BankContact) {
setToInput(contact.benefAccount)
guard let fromBank = vm.fromAccount?.bank else { return }
if fromBank == "MIB" {
let isMibInternal = contact.source == "MIB" && contact.benefType != "LOCAL"
vm.lookupState = .found(MibAccountLookupResult(
accountNumber: contact.benefAccount,
accountName: contact.displayName,
bankName: contact.benefBankName ?? (isMibInternal ? "Maldives Islamic Bank" : "Local Bank"),
bankCode: contact.bankCode ?? (isMibInternal ? "2" : "3"),
aliasId: contact.benefNo,
network: isMibInternal ? .mibInternal : .local
))
} else if fromBank == "BML" {
vm.bmlTrnType = contact.source == "MIB" ? "DOT" : "IAT"
vm.bmlBank = contact.source == "MIB" ? "MIB" : nil
vm.bmlCreditAccountOverride = nil
vm.lookupState = .bmlReady(contact.displayName)
}
}
private func contactCategoryTitle(_ contact: BankContact) -> String {
if let category = contact.benefCategoryId, !category.isEmpty, category != "BML" {
return category
}
switch contact.source {
case "MIB": return "MIB Contacts"
case "BML": return "BML Contacts"
default: return "Other Contacts"
}
}
private func contactSubtitle(_ contact: BankContact) -> String {
let bank = contact.benefBankName ?? contact.source
return "\(bank) · \(contact.benefAccount)"
}
private func accountInitials(_ account: BankAccount) -> String {
let label = account.accountBriefName.isEmpty ? account.accountTypeName : account.accountBriefName
let words = label.split(separator: " ").prefix(2)
let initials = words.compactMap { $0.first }.map(String.init).joined()
return initials.isEmpty ? account.bank : initials
}
private func isBmlCard(_ account: BankAccount) -> Bool {
account.profileType == "BML_CREDIT" ||
account.profileType == "BML_DEBIT" ||
account.profileType == "BML_PREPAID"
}
private func contactInitials(_ name: String) -> String {
let words = name.split(separator: " ").prefix(2)
return words.compactMap { $0.first }.map(String.init).joined()
}
private func contactColor(_ source: String) -> Color {
switch source {
case "MIB": return primary
case "BML": return .blue
default: return .purple
}
}
private func bankDot(_ bank: String) -> some View {
Circle()
.fill(bank == "MIB" ? Color(red: 0.247, green: 0.396, blue: 0.678) : Color.blue)
.frame(width: 10, height: 10)
}
}
#Preview {
TransferView()
.environment(HomeViewModel())
}