254 lines
8.1 KiB
Swift
254 lines
8.1 KiB
Swift
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())
|
|
}
|