BusinessCard/BusinessCard/Views/Features/Contacts/Components/ContactsListView.swift

122 lines
4.9 KiB
Swift

import SwiftUI
import Bedrock
struct ContactsListView: View {
@Bindable var contactsStore: ContactsStore
private var sortedContacts: [Contact] {
contactsStore.visibleContacts.sorted { lhs, rhs in
let left = sortKey(for: lhs.name)
let right = sortKey(for: rhs.name)
if left.isEmpty && right.isEmpty {
return lhs.lastSharedDate > rhs.lastSharedDate
}
if left.isEmpty { return false }
if right.isEmpty { return true }
return left.localizedCaseInsensitiveCompare(right) == .orderedAscending
}
}
private var contactsBySection: [String: [Contact]] {
Dictionary(grouping: sortedContacts) { contact in
sectionTitle(for: contact)
}
}
private var sectionTitles: [String] {
contactsBySection.keys.sorted { lhs, rhs in
if lhs == "#" { return false }
if rhs == "#" { return true }
return lhs < rhs
}
}
private var showsJumpIndex: Bool {
sortedContacts.count >= Design.Contacts.sectionIndexMinContacts
&& sectionTitles.count >= Design.Contacts.sectionIndexMinSections
}
var body: some View {
ScrollViewReader { proxy in
ZStack(alignment: .trailing) {
List {
ForEach(sectionTitles, id: \.self) { title in
let sectionContacts = contactsBySection[title] ?? []
Section {
ForEach(sectionContacts) { contact in
NavigationLink(value: contact) {
ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
}
}
.onDelete { indexSet in
for index in indexSet {
contactsStore.deleteContact(sectionContacts[index])
}
}
} header: {
Text(title)
.typography(.caption)
.foregroundStyle(Color.Text.tertiary)
.id(title)
}
}
}
.listStyle(.plain)
.navigationDestination(for: Contact.self) { contact in
ContactDetailView(contact: contact)
}
if showsJumpIndex {
VStack(spacing: Design.Contacts.sectionIndexLetterSpacing) {
ForEach(sectionTitles, id: \.self) { title in
Button {
withAnimation(.easeInOut(duration: Design.Contacts.sectionIndexScrollAnimationDuration)) {
proxy.scrollTo(title, anchor: .top)
}
} label: {
Text(title)
.typography(.caption2)
.foregroundStyle(Color.Text.secondary)
.frame(
width: Design.Contacts.sectionIndexLetterWidth,
height: Design.Contacts.sectionIndexLetterHeight
)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, Design.Spacing.small)
.padding(.horizontal, Design.Spacing.xxSmall)
.background(Color.AppBackground.base.opacity(0.86))
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.padding(.trailing, Design.Spacing.xxSmall)
}
}
}
}
private func sectionTitle(for contact: Contact) -> String {
let key = sortKey(for: contact.name)
guard let first = key.first else { return "#" }
let letter = String(first).uppercased()
return letter.rangeOfCharacter(from: .letters) != nil ? letter : "#"
}
/// Sorts by last-name-first when possible, falling back to full name.
private func sortKey(for fullName: String) -> String {
let trimmed = fullName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
let parts = trimmed.split(whereSeparator: \.isWhitespace).map(String.init)
guard let last = parts.last else { return trimmed }
if parts.count <= 1 {
return last
}
let given = parts.dropLast().joined(separator: " ")
return "\(last), \(given)"
}
}