122 lines
4.9 KiB
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)"
|
|
}
|
|
}
|