Files
Thijooree-iOS/Thijooree iOS/Views/Home/ContactsView.swift
T

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