Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
6c0fa315b2
commit
97d8b648e4
@ -3,16 +3,6 @@
|
|||||||
"strings" : {
|
"strings" : {
|
||||||
"(%@)" : {
|
"(%@)" : {
|
||||||
|
|
||||||
},
|
|
||||||
"%@, %@" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "new",
|
|
||||||
"value" : "%1$@, %2$@"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"%@, %@, %@" : {
|
"%@, %@, %@" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -55,6 +45,10 @@
|
|||||||
},
|
},
|
||||||
"Accreditations" : {
|
"Accreditations" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Add" : {
|
||||||
|
"comment" : "A button label that says \"Add\".",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Add %@" : {
|
"Add %@" : {
|
||||||
|
|
||||||
@ -84,12 +78,6 @@
|
|||||||
},
|
},
|
||||||
"Add Contact Fields" : {
|
"Add Contact Fields" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Add note" : {
|
|
||||||
|
|
||||||
},
|
|
||||||
"Add tag" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Address" : {
|
"Address" : {
|
||||||
|
|
||||||
@ -466,9 +454,6 @@
|
|||||||
},
|
},
|
||||||
"Middle Name" : {
|
"Middle Name" : {
|
||||||
|
|
||||||
},
|
|
||||||
"More..." : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Next step: create your first card. Once it is saved, you can start sharing immediately." : {
|
"Next step: create your first card. Once it is saved, you can start sharing immediately." : {
|
||||||
"comment" : "A description of the next step in the onboarding process, where a user can create their first card.",
|
"comment" : "A description of the next step in the onboarding process, where a user can create their first card.",
|
||||||
@ -479,6 +464,9 @@
|
|||||||
},
|
},
|
||||||
"No contacts yet" : {
|
"No contacts yet" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"No notes yet" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Notes" : {
|
"Notes" : {
|
||||||
|
|
||||||
@ -663,6 +651,13 @@
|
|||||||
},
|
},
|
||||||
"Selected" : {
|
"Selected" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Separate tags with commas" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Share" : {
|
||||||
|
"comment" : "A button label that triggers a share action.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Share card" : {
|
"Share card" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -736,9 +731,6 @@
|
|||||||
"Share your card quickly from your home screen and your watch face." : {
|
"Share your card quickly from your home screen and your watch face." : {
|
||||||
"comment" : "A description of how users can share their business cards on their iPhone and watch faces.",
|
"comment" : "A description of how users can share their business cards on their iPhone and watch faces.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
|
||||||
"Shared With" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"ShareEmailBodySimple" : {
|
"ShareEmailBodySimple" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -833,12 +825,19 @@
|
|||||||
},
|
},
|
||||||
"Support & Funding" : {
|
"Support & Funding" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Tags" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Tap + to add a contact, scan a QR code, or track who you share your card with." : {
|
"Tap + to add a contact, scan a QR code, or track who you share your card with." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Tap a field below to add it" : {
|
"Tap a field below to add it" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Tap Add to save context for this contact." : {
|
||||||
|
"comment" : "A description below the \"Add\" button in the \"Notes\" section of a contact detail card, explaining that tapping the button will allow the user to add a note to the contact.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Tap to choose how images appear in the card header" : {
|
"Tap to choose how images appear in the card header" : {
|
||||||
|
|
||||||
|
|||||||
@ -115,6 +115,48 @@ final class ContactsStore: ContactTracking {
|
|||||||
fetchContacts()
|
fetchContacts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates an existing contact and replaces its contact fields.
|
||||||
|
func updateContact(
|
||||||
|
_ contact: Contact,
|
||||||
|
name: String,
|
||||||
|
role: String = "",
|
||||||
|
company: String = "",
|
||||||
|
notes: String = "",
|
||||||
|
tags: String = "",
|
||||||
|
followUpDate: Date? = nil,
|
||||||
|
contactFields: [ContactField] = [],
|
||||||
|
photoData: Data? = nil
|
||||||
|
) {
|
||||||
|
contact.name = name
|
||||||
|
contact.role = role
|
||||||
|
contact.company = company
|
||||||
|
contact.notes = notes
|
||||||
|
contact.tags = tags
|
||||||
|
contact.followUpDate = followUpDate
|
||||||
|
contact.photoData = photoData
|
||||||
|
|
||||||
|
if let existingFields = contact.contactFields {
|
||||||
|
for field in existingFields {
|
||||||
|
modelContext.delete(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fieldsToAttach: [ContactField] = []
|
||||||
|
for (index, field) in contactFields.enumerated() {
|
||||||
|
field.orderIndex = index
|
||||||
|
field.contact = contact
|
||||||
|
modelContext.insert(field)
|
||||||
|
fieldsToAttach.append(field)
|
||||||
|
}
|
||||||
|
contact.contactFields = fieldsToAttach
|
||||||
|
|
||||||
|
contact.phone = fieldsToAttach.first(where: { $0.typeId == "phone" })?.value ?? ""
|
||||||
|
contact.email = fieldsToAttach.first(where: { $0.typeId == "email" })?.value ?? ""
|
||||||
|
|
||||||
|
saveContext()
|
||||||
|
fetchContacts()
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates a contact's notes
|
/// Updates a contact's notes
|
||||||
func updateNotes(for contact: Contact, notes: String) {
|
func updateNotes(for contact: Contact, notes: String) {
|
||||||
contact.notes = notes
|
contact.notes = notes
|
||||||
|
|||||||
@ -4,7 +4,20 @@ import Bedrock
|
|||||||
struct ContactRowView: View {
|
struct ContactRowView: View {
|
||||||
let contact: Contact
|
let contact: Contact
|
||||||
let relativeDate: String
|
let relativeDate: String
|
||||||
|
|
||||||
|
private var sourceBadgeText: String {
|
||||||
|
if contact.isReceivedCard {
|
||||||
|
return String.localized("Received")
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = contact.cardLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.localizedCaseInsensitiveCompare("Manual") == .orderedSame {
|
||||||
|
return String.localized("Added")
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.isEmpty ? String.localized("Shared") : String.localized(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
ContactAvatarView(contact: contact)
|
ContactAvatarView(contact: contact)
|
||||||
@ -14,10 +27,11 @@ struct ContactRowView: View {
|
|||||||
Text(contact.name)
|
Text(contact.name)
|
||||||
.typography(.heading)
|
.typography(.heading)
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
if contact.isReceivedCard {
|
if contact.isReceivedCard {
|
||||||
Image(systemName: "arrow.down.circle.fill")
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.foregroundStyle(Color.Accent.mint)
|
.foregroundStyle(Color.Accent.mint)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,9 +54,10 @@ struct ContactRowView: View {
|
|||||||
ForEach(contact.tagList.prefix(2), id: \.self) { tag in
|
ForEach(contact.tagList.prefix(2), id: \.self) { tag in
|
||||||
Text(tag)
|
Text(tag)
|
||||||
.typography(.caption2)
|
.typography(.caption2)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
.padding(.horizontal, Design.Spacing.xSmall)
|
.padding(.horizontal, Design.Spacing.xSmall)
|
||||||
.padding(.vertical, Design.Spacing.xxSmall)
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
.background(Color.AppBackground.accent)
|
.background(Color.AppBackground.accent.opacity(0.5))
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.small))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -55,16 +70,14 @@ struct ContactRowView: View {
|
|||||||
Text(relativeDate)
|
Text(relativeDate)
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.foregroundStyle(Color.Text.secondary)
|
.foregroundStyle(Color.Text.secondary)
|
||||||
Text(String.localized(contact.cardLabel))
|
Text(sourceBadgeText)
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.padding(.horizontal, Design.Spacing.small)
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
.padding(.vertical, Design.Spacing.xxSmall)
|
|
||||||
.background(Color.AppBackground.base)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
.accessibilityElement(children: .ignore)
|
.accessibilityElement(children: .ignore)
|
||||||
.accessibilityLabel(contact.name)
|
.accessibilityLabel(contact.name)
|
||||||
.accessibilityValue("\(contact.role), \(contact.company)")
|
.accessibilityValue("\(contact.role), \(contact.company), \(sourceBadgeText)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,64 +3,115 @@ import Bedrock
|
|||||||
|
|
||||||
struct ContactsListView: View {
|
struct ContactsListView: View {
|
||||||
@Bindable var contactsStore: ContactsStore
|
@Bindable var contactsStore: ContactsStore
|
||||||
|
|
||||||
var body: some View {
|
private var sortedContacts: [Contact] {
|
||||||
List {
|
contactsStore.visibleContacts.sorted { lhs, rhs in
|
||||||
let overdueContacts = contactsStore.visibleContacts.filter { $0.isFollowUpOverdue }
|
let left = sortKey(for: lhs.name)
|
||||||
if !overdueContacts.isEmpty {
|
let right = sortKey(for: rhs.name)
|
||||||
Section {
|
|
||||||
ForEach(overdueContacts) { contact in
|
if left.isEmpty && right.isEmpty {
|
||||||
NavigationLink(value: contact) {
|
return lhs.lastSharedDate > rhs.lastSharedDate
|
||||||
ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Label(String.localized("Follow-up Overdue"), systemImage: "exclamationmark.circle")
|
|
||||||
.foregroundStyle(Color.Accent.red)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if left.isEmpty { return false }
|
||||||
let receivedCards = contactsStore.visibleContacts.filter { $0.isReceivedCard && !$0.isFollowUpOverdue }
|
if right.isEmpty { return true }
|
||||||
if !receivedCards.isEmpty {
|
return left.localizedCaseInsensitiveCompare(right) == .orderedAscending
|
||||||
Section {
|
|
||||||
ForEach(receivedCards) { contact in
|
|
||||||
NavigationLink(value: contact) {
|
|
||||||
ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete { indexSet in
|
|
||||||
for index in indexSet {
|
|
||||||
contactsStore.deleteContact(receivedCards[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Label(String.localized("Received Cards"), systemImage: "tray.and.arrow.down")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let sharedContacts = contactsStore.visibleContacts.filter { !$0.isReceivedCard && !$0.isFollowUpOverdue }
|
|
||||||
if !sharedContacts.isEmpty {
|
|
||||||
Section {
|
|
||||||
ForEach(sharedContacts) { contact in
|
|
||||||
NavigationLink(value: contact) {
|
|
||||||
ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete { indexSet in
|
|
||||||
for index in indexSet {
|
|
||||||
contactsStore.deleteContact(sharedContacts[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Shared With")
|
|
||||||
.typography(.heading)
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.navigationDestination(for: Contact.self) { contact in
|
|
||||||
ContactDetailView(contact: contact)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 >= 20 && sectionTitles.count >= 6
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 2) {
|
||||||
|
ForEach(sectionTitles, id: \.self) { title in
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
proxy.scrollTo(title, anchor: .top)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(title)
|
||||||
|
.typography(.caption2)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.frame(width: 16, height: 12)
|
||||||
|
}
|
||||||
|
.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)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,10 +10,8 @@ struct ContactDetailView: View {
|
|||||||
@Bindable var contact: Contact
|
@Bindable var contact: Contact
|
||||||
|
|
||||||
@State private var showingDeleteConfirmation = false
|
@State private var showingDeleteConfirmation = false
|
||||||
@State private var showingMoreActions = false
|
@State private var showingEditContact = false
|
||||||
@State private var showingAddTag = false
|
@State private var noteEditorTarget: NoteEditorTarget?
|
||||||
@State private var showingAddNote = false
|
|
||||||
@State private var newTag = ""
|
|
||||||
|
|
||||||
// Photo picker state
|
// Photo picker state
|
||||||
@State private var showingPhotoSourcePicker = false
|
@State private var showingPhotoSourcePicker = false
|
||||||
@ -28,122 +26,44 @@ struct ContactDetailView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
|
Color.AppBackground.base
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: Design.Spacing.large) {
|
||||||
// Header banner with photo
|
ContactDetailCard(
|
||||||
ContactBannerView(
|
|
||||||
contact: contact,
|
contact: contact,
|
||||||
onEditPhoto: { showingPhotoSourcePicker = true }
|
onEditPhoto: { showingPhotoSourcePicker = true },
|
||||||
|
onAddNote: { noteEditorTarget = NoteEditorTarget(editingNote: nil) },
|
||||||
|
onSelectNote: { note in
|
||||||
|
noteEditorTarget = NoteEditorTarget(editingNote: note)
|
||||||
|
},
|
||||||
|
openURL: openURL
|
||||||
)
|
)
|
||||||
|
.frame(maxWidth: Design.CardSize.maxCardWidth)
|
||||||
|
|
||||||
// Content
|
// Spacer for bottom action bar
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
Spacer()
|
||||||
// Name
|
.frame(height: Design.Spacing.xLarge * 4)
|
||||||
Text(contact.name.isEmpty ? String.localized("Contact") : contact.name)
|
|
||||||
.typography(.hero)
|
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
|
|
||||||
// Connection details
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
|
||||||
Text("Connection details")
|
|
||||||
.typography(.subheading)
|
|
||||||
.foregroundStyle(Color.Text.tertiary)
|
|
||||||
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
Image(systemName: "calendar")
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
Text(contact.lastSharedDate, format: .dateTime.day().month().year().hour().minute())
|
|
||||||
.typography(.subheading)
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
ForEach(contact.tagList, id: \.self) { tag in
|
|
||||||
TagPill(text: tag, onDelete: {
|
|
||||||
removeTag(tag)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showingAddTag = true
|
|
||||||
} label: {
|
|
||||||
Label(String.localized("Add tag"), systemImage: "plus")
|
|
||||||
.typography(.subheading)
|
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.AppText.inverted)
|
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
|
||||||
.padding(.vertical, Design.Spacing.small)
|
|
||||||
.background(Color.Text.primary)
|
|
||||||
.clipShape(.capsule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contact info card - shows both legacy fields and new contact fields
|
|
||||||
ContactInfoCard(contact: contact, openURL: openURL)
|
|
||||||
|
|
||||||
// Notes section
|
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
|
||||||
Text("Notes")
|
|
||||||
.typography(.heading)
|
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
|
|
||||||
if contact.notes.isEmpty {
|
|
||||||
NotesEmptyState()
|
|
||||||
} else {
|
|
||||||
Text(contact.notes)
|
|
||||||
.typography(.body)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(Design.Spacing.medium)
|
|
||||||
.background(Color.AppBackground.card)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showingAddNote = true
|
|
||||||
} label: {
|
|
||||||
Label(String.localized("Add note"), systemImage: "plus")
|
|
||||||
.typography(.subheading)
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
|
||||||
.background(Color.AppBackground.card)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
|
||||||
.stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, Design.Spacing.medium)
|
|
||||||
|
|
||||||
// Spacer for bottom bar
|
|
||||||
Spacer()
|
|
||||||
.frame(height: Design.Spacing.xLarge * 4)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
|
||||||
.padding(.top, Design.Spacing.large)
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.vertical, Design.Spacing.xLarge)
|
||||||
}
|
}
|
||||||
.background(Color.AppBackground.secondary)
|
.scrollIndicators(.hidden)
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
|
|
||||||
// Bottom action bar
|
// Bottom action bar
|
||||||
BottomActionBar(
|
BottomActionBar(
|
||||||
onMore: { showingMoreActions = true },
|
shareText: shareText,
|
||||||
onAddTag: { showingAddTag = true },
|
contactName: contact.name,
|
||||||
onAddNote: { showingAddNote = true }
|
onDelete: { showingDeleteConfirmation = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
// Edit action - could navigate to edit mode
|
showingEditContact = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "square.and.pencil")
|
Image(systemName: "square.and.pencil")
|
||||||
.foregroundStyle(Color.AppText.inverted)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
@ -151,18 +71,6 @@ struct ContactDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
.confirmationDialog(String.localized("More"), isPresented: $showingMoreActions) {
|
|
||||||
Button(String.localized("Download contact")) {
|
|
||||||
// Download action
|
|
||||||
}
|
|
||||||
Button(String.localized("Share contact")) {
|
|
||||||
// Share action
|
|
||||||
}
|
|
||||||
Button(String.localized("Delete Contact"), role: .destructive) {
|
|
||||||
showingDeleteConfirmation = true
|
|
||||||
}
|
|
||||||
Button(String.localized("Cancel"), role: .cancel) { }
|
|
||||||
}
|
|
||||||
.alert(String.localized("Delete Contact"), isPresented: $showingDeleteConfirmation) {
|
.alert(String.localized("Delete Contact"), isPresented: $showingDeleteConfirmation) {
|
||||||
Button(String.localized("Cancel"), role: .cancel) { }
|
Button(String.localized("Cancel"), role: .cancel) { }
|
||||||
Button(String.localized("Delete"), role: .destructive) {
|
Button(String.localized("Delete"), role: .destructive) {
|
||||||
@ -172,17 +80,11 @@ struct ContactDetailView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("Are you sure you want to delete this contact?")
|
Text("Are you sure you want to delete this contact?")
|
||||||
}
|
}
|
||||||
.alert(String.localized("Add Tag"), isPresented: $showingAddTag) {
|
.sheet(item: $noteEditorTarget) { target in
|
||||||
TextField(String.localized("Tag name"), text: $newTag)
|
AddNoteSheet(notes: $contact.notes, editingNote: target.editingNote)
|
||||||
Button(String.localized("Cancel"), role: .cancel) {
|
|
||||||
newTag = ""
|
|
||||||
}
|
|
||||||
Button(String.localized("Add")) {
|
|
||||||
addTag()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAddNote) {
|
.sheet(isPresented: $showingEditContact) {
|
||||||
AddNoteSheet(notes: $contact.notes)
|
AddContactSheet(contact: contact)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingPhotoSourcePicker, onDismiss: {
|
.sheet(isPresented: $showingPhotoSourcePicker, onDismiss: {
|
||||||
guard let action = pendingAction else { return }
|
guard let action = pendingAction else { return }
|
||||||
@ -243,31 +145,219 @@ struct ContactDetailView: View {
|
|||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addTag() {
|
private var shareText: String {
|
||||||
let tag = newTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
var lines: [String] = []
|
||||||
guard !tag.isEmpty else { return }
|
lines.append(contact.name.isEmpty ? String.localized("Contact") : contact.name)
|
||||||
|
|
||||||
if contact.tags.isEmpty {
|
if !contact.role.isEmpty || !contact.company.isEmpty {
|
||||||
contact.tags = tag
|
lines.append([contact.role, contact.company].filter { !$0.isEmpty }.joined(separator: " at "))
|
||||||
} else {
|
|
||||||
contact.tags += ", \(tag)"
|
|
||||||
}
|
}
|
||||||
newTag = ""
|
|
||||||
}
|
for field in contact.sortedContactFields where !field.value.isEmpty {
|
||||||
|
let title = field.title.isEmpty ? field.displayName : field.title
|
||||||
private func removeTag(_ tag: String) {
|
lines.append("\(title): \(field.displayValue)")
|
||||||
var tags = contact.tagList
|
}
|
||||||
tags.removeAll { $0 == tag }
|
|
||||||
contact.tags = tags.joined(separator: ", ")
|
let noteItems = ContactNoteCodec.decode(contact.notes)
|
||||||
|
if !noteItems.isEmpty {
|
||||||
|
lines.append("")
|
||||||
|
lines.append(String.localized("Notes") + ":")
|
||||||
|
for note in noteItems {
|
||||||
|
lines.append("- \(note.summary)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct NoteEditorTarget: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let editingNote: ContactNoteItem?
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Banner View
|
// MARK: - Banner View
|
||||||
|
|
||||||
|
private struct ContactDetailCard: View {
|
||||||
|
let contact: Contact
|
||||||
|
let onEditPhoto: () -> Void
|
||||||
|
let onAddNote: () -> Void
|
||||||
|
let onSelectNote: (ContactNoteItem) -> Void
|
||||||
|
let openURL: (String) -> Void
|
||||||
|
|
||||||
|
private var noteItems: [ContactNoteItem] {
|
||||||
|
ContactNoteCodec
|
||||||
|
.decode(contact.notes)
|
||||||
|
.enumerated()
|
||||||
|
.sorted { lhs, rhs in
|
||||||
|
let leftDate = lhs.element.createdAt ?? .distantPast
|
||||||
|
let rightDate = rhs.element.createdAt ?? .distantPast
|
||||||
|
if leftDate == rightDate {
|
||||||
|
// Keep most recently added entries first when timestamps match/missing.
|
||||||
|
return lhs.offset > rhs.offset
|
||||||
|
}
|
||||||
|
return leftDate > rightDate
|
||||||
|
}
|
||||||
|
.map(\.element)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ContactBannerView(contact: contact, onEditPhoto: onEditPhoto)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(contact.name.isEmpty ? String.localized("Contact") : contact.name)
|
||||||
|
.typography(.title2)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
if !contact.role.isEmpty {
|
||||||
|
Text(contact.role)
|
||||||
|
.typography(.heading)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contact.company.isEmpty {
|
||||||
|
Text(contact.company)
|
||||||
|
.typography(.subheading)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
Text("Connection details")
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Text(contact.lastSharedDate, format: .dateTime.day().month().year().hour().minute())
|
||||||
|
.typography(.subheading)
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !contact.tagList.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
ForEach(contact.tagList, id: \.self) { tag in
|
||||||
|
TagPill(text: tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
|
|
||||||
|
ContactInfoCard(contact: contact, openURL: openURL)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
HStack {
|
||||||
|
Text("Notes")
|
||||||
|
.typography(.heading)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Add", action: onAddNote)
|
||||||
|
.typography(.subheading)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
if noteItems.isEmpty {
|
||||||
|
Text("No notes yet")
|
||||||
|
.typography(.subheading)
|
||||||
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
|
Text("Tap Add to save context for this contact.")
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
|
} else {
|
||||||
|
ForEach(Array(noteItems.enumerated()), id: \.offset) { index, note in
|
||||||
|
Button {
|
||||||
|
onSelectNote(note)
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(note.summary)
|
||||||
|
.typography(.subheading)
|
||||||
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Text(note.timestampDisplay)
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if index < noteItems.count - 1 {
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, Design.Spacing.xxSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(Color.AppBackground.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.bottom, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.xLarge))
|
||||||
|
.shadow(
|
||||||
|
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
||||||
|
radius: Design.Shadow.radiusLarge,
|
||||||
|
x: Design.Shadow.offsetNone,
|
||||||
|
y: Design.Shadow.offsetMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct ContactBannerView: View {
|
private struct ContactBannerView: View {
|
||||||
let contact: Contact
|
let contact: Contact
|
||||||
let onEditPhoto: () -> Void
|
let onEditPhoto: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
Button(action: onEditPhoto) {
|
||||||
|
ContactProfileAvatarView(contact: contact)
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
.typography(.caption)
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.padding(Design.Spacing.xSmall)
|
||||||
|
.background(Color.CardPalette.coral)
|
||||||
|
.clipShape(.circle)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.standard)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(contact.photoData == nil ? String.localized("Add photo") : String.localized("Edit photo"))
|
||||||
|
.padding(.top, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.bottom, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tag Pill
|
||||||
|
|
||||||
|
private struct ContactProfileAvatarView: View {
|
||||||
|
let contact: Contact
|
||||||
|
|
||||||
private var initials: String {
|
private var initials: String {
|
||||||
let parts = contact.name.split(separator: " ")
|
let parts = contact.name.split(separator: " ")
|
||||||
if parts.count >= 2 {
|
if parts.count >= 2 {
|
||||||
@ -279,89 +369,38 @@ private struct ContactBannerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
Group {
|
||||||
// Background: photo or gradient
|
|
||||||
if let photoData = contact.photoData, let uiImage = UIImage(data: photoData) {
|
if let photoData = contact.photoData, let uiImage = UIImage(data: photoData) {
|
||||||
Image(uiImage: uiImage)
|
Image(uiImage: uiImage)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(height: Design.CardSize.bannerHeight * 1.5)
|
|
||||||
.clipped()
|
|
||||||
.overlay(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.clear, .black.opacity(Design.Opacity.light)],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// Gradient background
|
Text(initials)
|
||||||
LinearGradient(
|
.typography(.title3)
|
||||||
colors: [
|
.bold()
|
||||||
Color.CardPalette.coral,
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
Color.CardPalette.coral.opacity(Design.Opacity.strong)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
],
|
.background(Color.CardPalette.coral)
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
|
|
||||||
// Decorative circles
|
|
||||||
Circle()
|
|
||||||
.fill(Color.white.opacity(Design.Opacity.subtle))
|
|
||||||
.frame(width: Design.CardSize.qrSize, height: Design.CardSize.qrSize)
|
|
||||||
.offset(y: -Design.Spacing.xLarge)
|
|
||||||
|
|
||||||
// Initials
|
|
||||||
VStack(spacing: Design.Spacing.xxSmall) {
|
|
||||||
Text(String(initials.prefix(1)))
|
|
||||||
.typography(.title2)
|
|
||||||
Text(String(initials.dropFirst().prefix(1)))
|
|
||||||
.typography(.title2)
|
|
||||||
}
|
|
||||||
.foregroundStyle(Color.white.opacity(Design.Opacity.accent))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit photo button
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
Button(action: onEditPhoto) {
|
|
||||||
Image(systemName: contact.photoData == nil ? "camera.fill" : "pencil")
|
|
||||||
.typography(.body)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.padding(Design.Spacing.medium)
|
|
||||||
.background(.ultraThinMaterial)
|
|
||||||
.clipShape(.circle)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel(contact.photoData == nil ? String.localized("Add photo") : String.localized("Change photo"))
|
|
||||||
}
|
|
||||||
.padding(Design.Spacing.large)
|
|
||||||
.padding(.top, Design.Spacing.xLarge)
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: Design.CardSize.bannerHeight * 1.5)
|
.frame(width: 92, height: 92)
|
||||||
|
.clipShape(.circle)
|
||||||
|
.overlay(Circle().stroke(Color.AppBackground.elevated, lineWidth: Design.LineWidth.thick))
|
||||||
|
.shadow(
|
||||||
|
color: Color.Text.secondary.opacity(Design.Opacity.hint),
|
||||||
|
radius: Design.Shadow.radiusSmall,
|
||||||
|
x: Design.Shadow.offsetNone,
|
||||||
|
y: Design.Shadow.offsetSmall
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tag Pill
|
|
||||||
|
|
||||||
private struct TagPill: View {
|
private struct TagPill: View {
|
||||||
let text: String
|
let text: String
|
||||||
let onDelete: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Design.Spacing.xSmall) {
|
Text(text)
|
||||||
Text(text)
|
.typography(.subheading)
|
||||||
.typography(.subheading)
|
|
||||||
Button {
|
|
||||||
onDelete()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.typography(.caption2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
.padding(.vertical, Design.Spacing.small)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
@ -412,7 +451,7 @@ private struct ContactInfoCard: View {
|
|||||||
|
|
||||||
if index < allContactFields.count - 1 || hasLegacyFields {
|
if index < allContactFields.count - 1 || hasLegacyFields {
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
|
.padding(.leading, 28 + Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -427,7 +466,7 @@ private struct ContactInfoCard: View {
|
|||||||
|
|
||||||
if !contact.email.isEmpty && contact.emailAddresses.isEmpty {
|
if !contact.email.isEmpty && contact.emailAddresses.isEmpty {
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
|
.padding(.leading, 28 + Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,29 +498,28 @@ private struct ContactFieldInfoRow: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
||||||
// Icon circle
|
|
||||||
field.iconImage()
|
field.iconImage()
|
||||||
.typography(.body)
|
.typography(.subheading)
|
||||||
.foregroundStyle(Color.white)
|
.foregroundStyle(Color.CardPalette.coral)
|
||||||
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
.frame(width: 28)
|
||||||
.background(Color.CardPalette.coral)
|
|
||||||
.clipShape(.circle)
|
|
||||||
|
|
||||||
// Text
|
VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
|
||||||
Text(field.displayValue)
|
Text(field.displayValue)
|
||||||
.typography(.body)
|
.typography(.subheading)
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
Text(field.title.isEmpty ? field.displayName : field.title)
|
Text(field.title.isEmpty ? field.displayName : field.title)
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.foregroundStyle(Color.Text.tertiary)
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@ -497,28 +535,28 @@ private struct ContactInfoRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(alignment: .top, spacing: Design.Spacing.medium) {
|
||||||
// Icon circle
|
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.typography(.body)
|
.typography(.subheading)
|
||||||
.foregroundStyle(Color.white)
|
.foregroundStyle(Color.CardPalette.coral)
|
||||||
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
.frame(width: 28)
|
||||||
.background(Color.CardPalette.coral)
|
|
||||||
.clipShape(.circle)
|
|
||||||
|
|
||||||
// Text
|
VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) {
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
|
||||||
Text(value)
|
Text(value)
|
||||||
.typography(.body)
|
.typography(.subheading)
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(2)
|
||||||
Text(label)
|
Text(label)
|
||||||
.typography(.caption)
|
.typography(.caption)
|
||||||
.foregroundStyle(Color.Text.tertiary)
|
.foregroundStyle(Color.Text.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@ -548,14 +586,14 @@ private struct NotesEmptyState: View {
|
|||||||
// MARK: - Bottom Action Bar
|
// MARK: - Bottom Action Bar
|
||||||
|
|
||||||
private struct BottomActionBar: View {
|
private struct BottomActionBar: View {
|
||||||
let onMore: () -> Void
|
let shareText: String
|
||||||
let onAddTag: () -> Void
|
let contactName: String
|
||||||
let onAddNote: () -> Void
|
let onDelete: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
Button(action: onMore) {
|
ShareLink(item: shareText, subject: Text(contactName)) {
|
||||||
Text("More...")
|
Text("Share")
|
||||||
.typography(.subheading)
|
.typography(.subheading)
|
||||||
.bold()
|
.bold()
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
@ -564,26 +602,16 @@ private struct BottomActionBar: View {
|
|||||||
.background(Color.AppBackground.card)
|
.background(Color.AppBackground.card)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Button(action: onAddTag) {
|
Button(role: .destructive, action: onDelete) {
|
||||||
Text("Add tag")
|
Text("Delete")
|
||||||
.typography(.subheading)
|
.typography(.subheading)
|
||||||
.bold()
|
.bold()
|
||||||
.foregroundStyle(Color.AppText.inverted)
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
.background(Color.Text.primary)
|
.background(Color.Accent.red)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: onAddNote) {
|
|
||||||
Text("Add note")
|
|
||||||
.typography(.subheading)
|
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.AppText.inverted)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
|
||||||
.background(Color.Text.primary)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -598,13 +626,14 @@ private struct BottomActionBar: View {
|
|||||||
private struct AddNoteSheet: View {
|
private struct AddNoteSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Binding var notes: String
|
@Binding var notes: String
|
||||||
@State private var editedNotes: String = ""
|
let editingNote: ContactNoteItem?
|
||||||
|
@State private var newNote: String = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
TextEditor(text: $editedNotes)
|
TextEditor(text: $newNote)
|
||||||
.padding(Design.Spacing.medium)
|
.padding(Design.Spacing.medium)
|
||||||
.navigationTitle(String.localized("Notes"))
|
.navigationTitle(editingNote == nil ? String.localized("Add Note") : String.localized("Edit Note"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.keyboardDismissable()
|
.keyboardDismissable()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@ -615,19 +644,120 @@ private struct AddNoteSheet: View {
|
|||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button(String.localized("Save")) {
|
Button(String.localized("Save")) {
|
||||||
notes = editedNotes
|
let trimmed = newNote.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
|
||||||
|
notes = ContactNoteCodec.upsert(
|
||||||
|
raw: notes,
|
||||||
|
at: editingNote?.sourceIndex,
|
||||||
|
summary: trimmed,
|
||||||
|
updatedAt: .now
|
||||||
|
)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.disabled(newNote.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if editingNote != nil {
|
||||||
|
ToolbarItem(placement: .bottomBar) {
|
||||||
|
Button(String.localized("Delete"), role: .destructive) {
|
||||||
|
notes = ContactNoteCodec.delete(raw: notes, at: editingNote?.sourceIndex)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
editedNotes = notes
|
newNote = editingNote?.summary ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ContactNoteItem {
|
||||||
|
let sourceIndex: Int
|
||||||
|
let summary: String
|
||||||
|
let createdAt: Date?
|
||||||
|
|
||||||
|
var timestampDisplay: String {
|
||||||
|
guard let createdAt else { return String.localized("Unknown time") }
|
||||||
|
return createdAt.formatted(.dateTime.month().day().hour().minute())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ContactNoteCodec {
|
||||||
|
private static let iso8601: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let prefix = "@note("
|
||||||
|
private static let separator = "): "
|
||||||
|
|
||||||
|
static func encode(summary: String, createdAt: Date) -> String {
|
||||||
|
let normalized = summary
|
||||||
|
.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return "\(prefix)\(iso8601.string(from: createdAt))\(separator)\(normalized)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ raw: String) -> [ContactNoteItem] {
|
||||||
|
let blocks = raw
|
||||||
|
.components(separatedBy: "\n\n")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
|
||||||
|
return blocks.enumerated().map { index, block in
|
||||||
|
guard block.hasPrefix(prefix),
|
||||||
|
let closeRange = block.range(of: separator),
|
||||||
|
closeRange.lowerBound > block.startIndex else {
|
||||||
|
return ContactNoteItem(sourceIndex: index, summary: block, createdAt: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestampStart = block.index(block.startIndex, offsetBy: prefix.count)
|
||||||
|
let timestamp = String(block[timestampStart..<closeRange.lowerBound])
|
||||||
|
let summaryStart = closeRange.upperBound
|
||||||
|
let summary = String(block[summaryStart...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let date = iso8601.date(from: timestamp)
|
||||||
|
return ContactNoteItem(
|
||||||
|
sourceIndex: index,
|
||||||
|
summary: summary.isEmpty ? String.localized("Empty note") : summary,
|
||||||
|
createdAt: date
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func upsert(raw: String, at sourceIndex: Int?, summary: String, updatedAt: Date) -> String {
|
||||||
|
let encoded = encode(summary: summary, createdAt: updatedAt)
|
||||||
|
var blocks = splitBlocks(raw)
|
||||||
|
|
||||||
|
if let sourceIndex, blocks.indices.contains(sourceIndex) {
|
||||||
|
blocks[sourceIndex] = encoded
|
||||||
|
} else {
|
||||||
|
blocks.append(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks.joined(separator: "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(raw: String, at sourceIndex: Int?) -> String {
|
||||||
|
guard let sourceIndex else { return raw }
|
||||||
|
var blocks = splitBlocks(raw)
|
||||||
|
guard blocks.indices.contains(sourceIndex) else { return raw }
|
||||||
|
blocks.remove(at: sourceIndex)
|
||||||
|
return blocks.joined(separator: "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func splitBlocks(_ raw: String) -> [String] {
|
||||||
|
raw
|
||||||
|
.components(separatedBy: "\n\n")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ContactDetailView(
|
ContactDetailView(
|
||||||
|
|||||||
@ -7,6 +7,8 @@ struct AddContactSheet: View {
|
|||||||
@Environment(AppState.self) private var appState
|
@Environment(AppState.self) private var appState
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let contact: Contact?
|
||||||
|
|
||||||
// Photo
|
// Photo
|
||||||
@State private var photoData: Data?
|
@State private var photoData: Data?
|
||||||
@State private var showingPhotoSourcePicker = false
|
@State private var showingPhotoSourcePicker = false
|
||||||
@ -34,6 +36,8 @@ struct AddContactSheet: View {
|
|||||||
|
|
||||||
// Notes
|
// Notes
|
||||||
@State private var notes = ""
|
@State private var notes = ""
|
||||||
|
@State private var tags = ""
|
||||||
|
@State private var didLoadExistingValues = false
|
||||||
|
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
let hasName = !firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
let hasName = !firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
||||||
@ -69,6 +73,14 @@ struct AddContactSheet: View {
|
|||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isEditing: Bool {
|
||||||
|
contact != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init(contact: Contact? = nil) {
|
||||||
|
self.contact = contact
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
@ -177,6 +189,16 @@ struct AddContactSheet: View {
|
|||||||
Text("Links")
|
Text("Links")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField(String.localized("e.g. VIP, Lead, Realtor"), text: $tags)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.words)
|
||||||
|
} header: {
|
||||||
|
Text("Tags")
|
||||||
|
} footer: {
|
||||||
|
Text("Separate tags with commas")
|
||||||
|
}
|
||||||
|
|
||||||
// Notes section
|
// Notes section
|
||||||
Section {
|
Section {
|
||||||
TextField(String.localized("Notes about this contact..."), text: $notes, axis: .vertical)
|
TextField(String.localized("Notes about this contact..."), text: $notes, axis: .vertical)
|
||||||
@ -185,9 +207,12 @@ struct AddContactSheet: View {
|
|||||||
Text("Notes")
|
Text("Notes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(String.localized("New contact"))
|
.navigationTitle(isEditing ? String.localized("Edit contact") : String.localized("New contact"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.keyboardDismissable()
|
.keyboardDismissable()
|
||||||
|
.onAppear {
|
||||||
|
loadExistingValuesIfNeeded()
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingPhotoSourcePicker, onDismiss: {
|
.sheet(isPresented: $showingPhotoSourcePicker, onDismiss: {
|
||||||
guard let action = pendingAction else { return }
|
guard let action = pendingAction else { return }
|
||||||
pendingAction = nil
|
pendingAction = nil
|
||||||
@ -309,17 +334,78 @@ struct AddContactSheet: View {
|
|||||||
orderIndex += 1
|
orderIndex += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
appState.contactsStore.createContact(
|
let trimmedTags = tags
|
||||||
name: fullName,
|
.split(separator: ",")
|
||||||
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
.filter { !$0.isEmpty }
|
||||||
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
.joined(separator: ", ")
|
||||||
followUpDate: appState.appSettings.defaultFollowUpPreset.followUpDate(from: .now),
|
|
||||||
contactFields: contactFields,
|
if let contact {
|
||||||
photoData: photoData
|
appState.contactsStore.updateContact(
|
||||||
)
|
contact,
|
||||||
|
name: fullName,
|
||||||
|
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
tags: trimmedTags,
|
||||||
|
followUpDate: contact.followUpDate,
|
||||||
|
contactFields: contactFields,
|
||||||
|
photoData: photoData
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
appState.contactsStore.createContact(
|
||||||
|
name: fullName,
|
||||||
|
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
tags: trimmedTags,
|
||||||
|
followUpDate: appState.appSettings.defaultFollowUpPreset.followUpDate(from: .now),
|
||||||
|
contactFields: contactFields,
|
||||||
|
photoData: photoData
|
||||||
|
)
|
||||||
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadExistingValuesIfNeeded() {
|
||||||
|
guard !didLoadExistingValues, let contact else { return }
|
||||||
|
didLoadExistingValues = true
|
||||||
|
|
||||||
|
let nameParts = contact.name.split(separator: " ", maxSplits: 1).map(String.init)
|
||||||
|
firstName = nameParts.first ?? ""
|
||||||
|
lastName = nameParts.count > 1 ? nameParts[1] : ""
|
||||||
|
|
||||||
|
jobTitle = contact.role
|
||||||
|
company = contact.company
|
||||||
|
notes = contact.notes
|
||||||
|
tags = contact.tags
|
||||||
|
photoData = contact.photoData
|
||||||
|
|
||||||
|
let existingPhones = contact.phoneNumbers.map {
|
||||||
|
LabeledEntry(label: $0.title.isEmpty ? "Cell" : $0.title, value: $0.value)
|
||||||
|
}
|
||||||
|
if !existingPhones.isEmpty {
|
||||||
|
phoneEntries = existingPhones
|
||||||
|
} else if !contact.phone.isEmpty {
|
||||||
|
phoneEntries = [LabeledEntry(label: "Cell", value: contact.phone)]
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingEmails = contact.emailAddresses.map {
|
||||||
|
LabeledEntry(label: $0.title.isEmpty ? "Work" : $0.title, value: $0.value)
|
||||||
|
}
|
||||||
|
if !existingEmails.isEmpty {
|
||||||
|
emailEntries = existingEmails
|
||||||
|
} else if !contact.email.isEmpty {
|
||||||
|
emailEntries = [LabeledEntry(label: "Work", value: contact.email)]
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingLinks = contact.links.map {
|
||||||
|
LabeledEntry(label: $0.title.isEmpty ? "Website" : $0.title, value: $0.value)
|
||||||
|
}
|
||||||
|
if !existingLinks.isEmpty {
|
||||||
|
linkEntries = existingLinks
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user