diff --git a/BusinessCard/Resources/Localizable.xcstrings b/BusinessCard/Resources/Localizable.xcstrings index 5bae403..c3fa779 100644 --- a/BusinessCard/Resources/Localizable.xcstrings +++ b/BusinessCard/Resources/Localizable.xcstrings @@ -3,16 +3,6 @@ "strings" : { "(%@)" : { - }, - "%@, %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@, %2$@" - } - } - } }, "%@, %@, %@" : { "localizations" : { @@ -55,6 +45,10 @@ }, "Accreditations" : { + }, + "Add" : { + "comment" : "A button label that says \"Add\".", + "isCommentAutoGenerated" : true }, "Add %@" : { @@ -84,12 +78,6 @@ }, "Add Contact Fields" : { - }, - "Add note" : { - - }, - "Add tag" : { - }, "Address" : { @@ -466,9 +454,6 @@ }, "Middle Name" : { - }, - "More..." : { - }, "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.", @@ -479,6 +464,9 @@ }, "No contacts yet" : { + }, + "No notes yet" : { + }, "Notes" : { @@ -663,6 +651,13 @@ }, "Selected" : { + }, + "Separate tags with commas" : { + + }, + "Share" : { + "comment" : "A button label that triggers a share action.", + "isCommentAutoGenerated" : true }, "Share card" : { "extractionState" : "stale", @@ -736,9 +731,6 @@ "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.", "isCommentAutoGenerated" : true - }, - "Shared With" : { - }, "ShareEmailBodySimple" : { "extractionState" : "stale", @@ -833,12 +825,19 @@ }, "Support & Funding" : { + }, + "Tags" : { + }, "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 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" : { diff --git a/BusinessCard/State/ContactsStore.swift b/BusinessCard/State/ContactsStore.swift index 9c7524a..7cd189b 100644 --- a/BusinessCard/State/ContactsStore.swift +++ b/BusinessCard/State/ContactsStore.swift @@ -115,6 +115,48 @@ final class ContactsStore: ContactTracking { 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 func updateNotes(for contact: Contact, notes: String) { contact.notes = notes diff --git a/BusinessCard/Views/Features/Contacts/Components/ContactRowView.swift b/BusinessCard/Views/Features/Contacts/Components/ContactRowView.swift index 710c15b..eab7351 100644 --- a/BusinessCard/Views/Features/Contacts/Components/ContactRowView.swift +++ b/BusinessCard/Views/Features/Contacts/Components/ContactRowView.swift @@ -4,7 +4,20 @@ import Bedrock struct ContactRowView: View { let contact: Contact 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 { HStack(spacing: Design.Spacing.medium) { ContactAvatarView(contact: contact) @@ -14,10 +27,11 @@ struct ContactRowView: View { Text(contact.name) .typography(.heading) .foregroundStyle(Color.Text.primary) + .lineLimit(1) if contact.isReceivedCard { Image(systemName: "arrow.down.circle.fill") - .typography(.caption) + .typography(.caption) .foregroundStyle(Color.Accent.mint) } @@ -40,9 +54,10 @@ struct ContactRowView: View { ForEach(contact.tagList.prefix(2), id: \.self) { tag in Text(tag) .typography(.caption2) + .foregroundStyle(Color.Text.secondary) .padding(.horizontal, Design.Spacing.xSmall) .padding(.vertical, Design.Spacing.xxSmall) - .background(Color.AppBackground.accent) + .background(Color.AppBackground.accent.opacity(0.5)) .clipShape(.rect(cornerRadius: Design.CornerRadius.small)) } } @@ -55,16 +70,14 @@ struct ContactRowView: View { Text(relativeDate) .typography(.caption) .foregroundStyle(Color.Text.secondary) - Text(String.localized(contact.cardLabel)) + Text(sourceBadgeText) .typography(.caption) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xxSmall) - .background(Color.AppBackground.base) - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + .foregroundStyle(Color.Text.tertiary) } } + .padding(.vertical, Design.Spacing.xxSmall) .accessibilityElement(children: .ignore) .accessibilityLabel(contact.name) - .accessibilityValue("\(contact.role), \(contact.company)") + .accessibilityValue("\(contact.role), \(contact.company), \(sourceBadgeText)") } } diff --git a/BusinessCard/Views/Features/Contacts/Components/ContactsListView.swift b/BusinessCard/Views/Features/Contacts/Components/ContactsListView.swift index 5b9df96..5b6fe92 100644 --- a/BusinessCard/Views/Features/Contacts/Components/ContactsListView.swift +++ b/BusinessCard/Views/Features/Contacts/Components/ContactsListView.swift @@ -3,64 +3,115 @@ import Bedrock struct ContactsListView: View { @Bindable var contactsStore: ContactsStore - - var body: some View { - List { - let overdueContacts = contactsStore.visibleContacts.filter { $0.isFollowUpOverdue } - if !overdueContacts.isEmpty { - Section { - ForEach(overdueContacts) { contact in - NavigationLink(value: contact) { - ContactRowView(contact: contact, relativeDate: contactsStore.relativeShareDate(for: contact)) - } - } - } header: { - Label(String.localized("Follow-up Overdue"), systemImage: "exclamationmark.circle") - .foregroundStyle(Color.Accent.red) - } + + 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 } - - let receivedCards = contactsStore.visibleContacts.filter { $0.isReceivedCard && !$0.isFollowUpOverdue } - if !receivedCards.isEmpty { - 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) + 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 >= 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)" + } } diff --git a/BusinessCard/Views/Features/Contacts/Detail/ContactDetailView.swift b/BusinessCard/Views/Features/Contacts/Detail/ContactDetailView.swift index ec83b37..0e2aabe 100644 --- a/BusinessCard/Views/Features/Contacts/Detail/ContactDetailView.swift +++ b/BusinessCard/Views/Features/Contacts/Detail/ContactDetailView.swift @@ -10,10 +10,8 @@ struct ContactDetailView: View { @Bindable var contact: Contact @State private var showingDeleteConfirmation = false - @State private var showingMoreActions = false - @State private var showingAddTag = false - @State private var showingAddNote = false - @State private var newTag = "" + @State private var showingEditContact = false + @State private var noteEditorTarget: NoteEditorTarget? // Photo picker state @State private var showingPhotoSourcePicker = false @@ -28,122 +26,44 @@ struct ContactDetailView: View { var body: some View { ZStack(alignment: .bottom) { + Color.AppBackground.base + .ignoresSafeArea() + ScrollView { - VStack(spacing: 0) { - // Header banner with photo - ContactBannerView( + VStack(spacing: Design.Spacing.large) { + ContactDetailCard( 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 - VStack(alignment: .leading, spacing: Design.Spacing.large) { - // Name - 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) + // Spacer for bottom action bar + Spacer() + .frame(height: Design.Spacing.xLarge * 4) } + .frame(maxWidth: .infinity) + .padding(.horizontal, Design.Spacing.large) + .padding(.vertical, Design.Spacing.xLarge) } - .background(Color.AppBackground.secondary) - .ignoresSafeArea(edges: .top) + .scrollIndicators(.hidden) // Bottom action bar BottomActionBar( - onMore: { showingMoreActions = true }, - onAddTag: { showingAddTag = true }, - onAddNote: { showingAddNote = true } + shareText: shareText, + contactName: contact.name, + onDelete: { showingDeleteConfirmation = true } ) } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { - // Edit action - could navigate to edit mode + showingEditContact = true } label: { Image(systemName: "square.and.pencil") .foregroundStyle(Color.AppText.inverted) @@ -151,18 +71,6 @@ struct ContactDetailView: View { } } .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) { Button(String.localized("Cancel"), role: .cancel) { } Button(String.localized("Delete"), role: .destructive) { @@ -172,17 +80,11 @@ struct ContactDetailView: View { } message: { Text("Are you sure you want to delete this contact?") } - .alert(String.localized("Add Tag"), isPresented: $showingAddTag) { - TextField(String.localized("Tag name"), text: $newTag) - Button(String.localized("Cancel"), role: .cancel) { - newTag = "" - } - Button(String.localized("Add")) { - addTag() - } + .sheet(item: $noteEditorTarget) { target in + AddNoteSheet(notes: $contact.notes, editingNote: target.editingNote) } - .sheet(isPresented: $showingAddNote) { - AddNoteSheet(notes: $contact.notes) + .sheet(isPresented: $showingEditContact) { + AddContactSheet(contact: contact) } .sheet(isPresented: $showingPhotoSourcePicker, onDismiss: { guard let action = pendingAction else { return } @@ -243,31 +145,219 @@ struct ContactDetailView: View { UIApplication.shared.open(url) } - private func addTag() { - let tag = newTag.trimmingCharacters(in: .whitespacesAndNewlines) - guard !tag.isEmpty else { return } + private var shareText: String { + var lines: [String] = [] + lines.append(contact.name.isEmpty ? String.localized("Contact") : contact.name) - if contact.tags.isEmpty { - contact.tags = tag - } else { - contact.tags += ", \(tag)" + if !contact.role.isEmpty || !contact.company.isEmpty { + lines.append([contact.role, contact.company].filter { !$0.isEmpty }.joined(separator: " at ")) } - newTag = "" - } - - private func removeTag(_ tag: String) { - var tags = contact.tagList - tags.removeAll { $0 == tag } - contact.tags = tags.joined(separator: ", ") + + for field in contact.sortedContactFields where !field.value.isEmpty { + let title = field.title.isEmpty ? field.displayName : field.title + lines.append("\(title): \(field.displayValue)") + } + + 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 +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 { let contact: Contact 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 { let parts = contact.name.split(separator: " ") if parts.count >= 2 { @@ -279,89 +369,38 @@ private struct ContactBannerView: View { } var body: some View { - ZStack { - // Background: photo or gradient + Group { if let photoData = contact.photoData, let uiImage = UIImage(data: photoData) { Image(uiImage: uiImage) .resizable() .scaledToFill() - .frame(height: Design.CardSize.bannerHeight * 1.5) - .clipped() - .overlay( - LinearGradient( - colors: [.clear, .black.opacity(Design.Opacity.light)], - startPoint: .top, - endPoint: .bottom - ) - ) } else { - // Gradient background - LinearGradient( - colors: [ - Color.CardPalette.coral, - Color.CardPalette.coral.opacity(Design.Opacity.strong) - ], - 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() + Text(initials) + .typography(.title3) + .bold() + .foregroundStyle(Color.AppText.inverted) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.CardPalette.coral) } } - .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 { let text: String - let onDelete: () -> Void var body: some View { - HStack(spacing: Design.Spacing.xSmall) { - Text(text) - .typography(.subheading) - Button { - onDelete() - } label: { - Image(systemName: "xmark") - .typography(.caption2) - } - } + Text(text) + .typography(.subheading) .foregroundStyle(Color.Text.primary) .padding(.horizontal, Design.Spacing.medium) .padding(.vertical, Design.Spacing.small) @@ -412,7 +451,7 @@ private struct ContactInfoCard: View { if index < allContactFields.count - 1 || hasLegacyFields { 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 { Divider() - .padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize) + .padding(.leading, 28 + Design.Spacing.medium) } } @@ -459,29 +498,28 @@ private struct ContactFieldInfoRow: View { } } label: { HStack(alignment: .top, spacing: Design.Spacing.medium) { - // Icon circle field.iconImage() - .typography(.body) - .foregroundStyle(Color.white) - .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize) - .background(Color.CardPalette.coral) - .clipShape(.circle) + .typography(.subheading) + .foregroundStyle(Color.CardPalette.coral) + .frame(width: 28) - // Text - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) { Text(field.displayValue) - .typography(.body) + .typography(.subheading) .foregroundStyle(Color.Text.primary) .multilineTextAlignment(.leading) + .lineLimit(2) Text(field.title.isEmpty ? field.displayName : field.title) .typography(.caption) - .foregroundStyle(Color.Text.tertiary) + .foregroundStyle(Color.Text.secondary) + .lineLimit(1) } Spacer() } - .padding(Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .padding(.horizontal, Design.Spacing.medium) } .buttonStyle(.plain) } @@ -497,28 +535,28 @@ private struct ContactInfoRow: View { var body: some View { Button(action: action) { - HStack(spacing: Design.Spacing.medium) { - // Icon circle + HStack(alignment: .top, spacing: Design.Spacing.medium) { Image(systemName: icon) - .typography(.body) - .foregroundStyle(Color.white) - .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize) - .background(Color.CardPalette.coral) - .clipShape(.circle) + .typography(.subheading) + .foregroundStyle(Color.CardPalette.coral) + .frame(width: 28) - // Text - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { + VStack(alignment: .leading, spacing: Design.Spacing.xxxSmall) { Text(value) - .typography(.body) + .typography(.subheading) .foregroundStyle(Color.Text.primary) + .multilineTextAlignment(.leading) + .lineLimit(2) Text(label) .typography(.caption) - .foregroundStyle(Color.Text.tertiary) + .foregroundStyle(Color.Text.secondary) + .lineLimit(1) } Spacer() } - .padding(Design.Spacing.medium) + .padding(.vertical, Design.Spacing.small) + .padding(.horizontal, Design.Spacing.medium) } .buttonStyle(.plain) } @@ -548,14 +586,14 @@ private struct NotesEmptyState: View { // MARK: - Bottom Action Bar private struct BottomActionBar: View { - let onMore: () -> Void - let onAddTag: () -> Void - let onAddNote: () -> Void + let shareText: String + let contactName: String + let onDelete: () -> Void var body: some View { HStack(spacing: Design.Spacing.medium) { - Button(action: onMore) { - Text("More...") + ShareLink(item: shareText, subject: Text(contactName)) { + Text("Share") .typography(.subheading) .bold() .foregroundStyle(Color.Text.primary) @@ -564,26 +602,16 @@ private struct BottomActionBar: View { .background(Color.AppBackground.card) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) } + .buttonStyle(.plain) - Button(action: onAddTag) { - Text("Add tag") + Button(role: .destructive, action: onDelete) { + Text("Delete") .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)) - } - - 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) + .background(Color.Accent.red) .clipShape(.rect(cornerRadius: Design.CornerRadius.large)) } } @@ -598,13 +626,14 @@ private struct BottomActionBar: View { private struct AddNoteSheet: View { @Environment(\.dismiss) private var dismiss @Binding var notes: String - @State private var editedNotes: String = "" + let editingNote: ContactNoteItem? + @State private var newNote: String = "" var body: some View { NavigationStack { - TextEditor(text: $editedNotes) + TextEditor(text: $newNote) .padding(Design.Spacing.medium) - .navigationTitle(String.localized("Notes")) + .navigationTitle(editingNote == nil ? String.localized("Add Note") : String.localized("Edit Note")) .navigationBarTitleDisplayMode(.inline) .keyboardDismissable() .toolbar { @@ -615,19 +644,120 @@ private struct AddNoteSheet: View { } ToolbarItem(placement: .confirmationAction) { 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() } + .disabled(newNote.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .bold() } + + if editingNote != nil { + ToolbarItem(placement: .bottomBar) { + Button(String.localized("Delete"), role: .destructive) { + notes = ContactNoteCodec.delete(raw: notes, at: editingNote?.sourceIndex) + dismiss() + } + } + } } .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.. 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 { NavigationStack { ContactDetailView( diff --git a/BusinessCard/Views/Features/Contacts/Sheets/AddContactSheet.swift b/BusinessCard/Views/Features/Contacts/Sheets/AddContactSheet.swift index 1371c91..a63bbba 100644 --- a/BusinessCard/Views/Features/Contacts/Sheets/AddContactSheet.swift +++ b/BusinessCard/Views/Features/Contacts/Sheets/AddContactSheet.swift @@ -7,6 +7,8 @@ struct AddContactSheet: View { @Environment(AppState.self) private var appState @Environment(\.dismiss) private var dismiss + let contact: Contact? + // Photo @State private var photoData: Data? @State private var showingPhotoSourcePicker = false @@ -34,6 +36,8 @@ struct AddContactSheet: View { // Notes @State private var notes = "" + @State private var tags = "" + @State private var didLoadExistingValues = false private var canSave: Bool { let hasName = !firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || @@ -69,6 +73,14 @@ struct AddContactSheet: View { .joined(separator: " ") } + private var isEditing: Bool { + contact != nil + } + + init(contact: Contact? = nil) { + self.contact = contact + } + var body: some View { NavigationStack { Form { @@ -177,6 +189,16 @@ struct AddContactSheet: View { 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 Section { TextField(String.localized("Notes about this contact..."), text: $notes, axis: .vertical) @@ -185,9 +207,12 @@ struct AddContactSheet: View { Text("Notes") } } - .navigationTitle(String.localized("New contact")) + .navigationTitle(isEditing ? String.localized("Edit contact") : String.localized("New contact")) .navigationBarTitleDisplayMode(.inline) .keyboardDismissable() + .onAppear { + loadExistingValuesIfNeeded() + } .sheet(isPresented: $showingPhotoSourcePicker, onDismiss: { guard let action = pendingAction else { return } pendingAction = nil @@ -309,17 +334,78 @@ struct AddContactSheet: View { orderIndex += 1 } - appState.contactsStore.createContact( - name: fullName, - role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines), - company: company.trimmingCharacters(in: .whitespacesAndNewlines), - notes: notes.trimmingCharacters(in: .whitespacesAndNewlines), - followUpDate: appState.appSettings.defaultFollowUpPreset.followUpDate(from: .now), - contactFields: contactFields, - photoData: photoData - ) + let trimmedTags = tags + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: ", ") + + if let contact { + 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() } + + 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 {