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()) }