Implement bank transfer app flows
This commit is contained in:
@@ -0,0 +1,819 @@
|
||||
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
|
||||
}
|
||||
.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 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())
|
||||
}
|
||||
Reference in New Issue
Block a user