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