diff --git a/Flipcash/Core/Screens/Conversation/ContactCardView.swift b/Flipcash/Core/Screens/Conversation/ContactCardView.swift new file mode 100644 index 00000000..2127e0f8 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ContactCardView.swift @@ -0,0 +1,59 @@ +// +// ContactCardView.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import Contacts +import ContactsUI + +/// What the contact-card sheet shows: an existing address-book contact's card, +/// or an unknown counterpart offered up for adding to Contacts. Drives +/// `.sheet(item:)` directly. +enum ContactCard: Identifiable { + /// Native card for a contact already in the address book. + case existing(CNContact) + /// Native "Add to Contacts" sheet (Create New / Add to Existing) seeded + /// with what we know about a counterpart who isn't a contact yet. + case unknown(CNContact) + + var id: String { + switch self { + case .existing(let contact): return "existing-\(contact.identifier)" + case .unknown(let contact): return "unknown-\(contact.identifier)" + } + } +} + +/// The native iOS contact card (`CNContactViewController`), hosted in a +/// navigation controller so it presents cleanly as a sheet with a Done button. +struct ContactCardView: UIViewControllerRepresentable { + + let card: ContactCard + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UINavigationController { + let controller: CNContactViewController + switch card { + case .existing(let contact): + controller = CNContactViewController(for: contact) + case .unknown(let contact): + controller = CNContactViewController(forUnknownContact: contact) + // Required for the Create New / Add to Existing actions to save. + controller.contactStore = CNContactStore() + } + controller.allowsEditing = true + controller.allowsActions = true + // The existing card's own Edit button occupies the trailing slot, so + // Done goes leading for both modes to stay consistent. + controller.navigationItem.leftBarButtonItem = UIBarButtonItem( + systemItem: .done, + primaryAction: UIAction { [dismiss] _ in dismiss() } + ) + return UINavigationController(rootViewController: controller) + } + + func updateUIViewController(_ controller: UINavigationController, context: Context) {} +} diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 33ab8c82..ca6c345b 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -8,6 +8,8 @@ import SwiftUI import UIKit import Combine +import Contacts +import ContactsUI import FlipcashCore import FlipcashUI @@ -42,6 +44,7 @@ struct ConversationScreen: View { @State private var didInitialRead = false @State private var barModel = ConversationBarModel() @State private var navBarWidth: CGFloat = 0 + @State private var presentedCard: ContactCard? /// Horizontal space the back button (leading) reserves on each side of the /// centered title item, so the avatar + name can left-align inside a @@ -101,6 +104,22 @@ struct ConversationScreen: View { } } + /// Tapping the title opens the counterpart's contact card: their address-book + /// card when they're a contact, otherwise the native "Add to Contacts" sheet + /// seeded with their number. Inert only when neither a contact nor a phone + /// number is known. + private var titleTapAction: (() -> Void)? { + guard contact != nil || addableContactPhone != nil else { return nil } + return { openContactCard() } + } + + /// The counterpart's phone number when they're NOT yet an address-book + /// contact — the seed for the native "Add to Contacts" sheet. + private var addableContactPhone: String? { + guard contact == nil else { return nil } + return sendTarget?.phoneE164 + } + private var title: String { if let conversationID { return conversationController.displayName(forConversationID: conversationID) @@ -163,10 +182,15 @@ struct ConversationScreen: View { title: title, contact: contact, conversationID: conversationID, - width: max(navBarWidth - Self.titleSideInset * 2, 0) + width: max(navBarWidth - Self.titleSideInset * 2, 0), + onTap: titleTapAction ) } } + .sheet(item: $presentedCard) { card in + ContactCardView(card: card) + .ignoresSafeArea() + } .background { // Measure the bar width so the centered title item can be sized to // (almost) fill it — the system toolbar won't honor maxWidth on a @@ -244,6 +268,28 @@ struct ConversationScreen: View { conversationController.visibleConversationID = id } + /// Opens the native iOS contact card for the counterpart. An address-book + /// contact shows their card (fetched with the descriptor + /// `CNContactViewController` requires; nothing is shown if it can't be read, + /// e.g. deleted since sync). A non-contact with a known number opens the + /// "Add to Contacts" sheet seeded with that number. + private func openContactCard() { + if let contactId = contact?.contactId { + let keys = [CNContactViewController.descriptorForRequiredKeys()] + guard let fetched = try? CNContactStore().unifiedContact( + withIdentifier: contactId, + keysToFetch: keys + ) else { return } + presentedCard = .existing(fetched) + } else if let phone = addableContactPhone { + let unknown = CNMutableContact() + unknown.phoneNumbers = [ + CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: phone)) + ] + presentedCard = .unknown(unknown) + } + } + private func sendCash() { guard let sendTarget else { return } guard session.hasGiveableBalance(for: ratesController.rateForBalanceCurrency()) else { @@ -298,9 +344,36 @@ struct ConversationScreen: View { /// Avatar + name, left-aligned inside the centered principal slot (sized to /// the measured bar width; the system toolbar won't honor maxWidth on a -/// principal item). +/// principal item). When `onTap` is non-nil the whole item becomes a button +/// that opens the counterpart's contact card. private struct ConversationTitleItem: View { + let title: String + let contact: ResolvedContact? + let conversationID: ConversationID? + let width: CGFloat + let onTap: (() -> Void)? + + var body: some View { + let label = ConversationTitleLabel( + title: title, + contact: contact, + conversationID: conversationID, + width: width + ) + if let onTap { + Button(action: onTap) { label } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityHint(contact != nil ? "Opens contact card" : "Adds to Contacts") + } else { + label + } + } +} + +private struct ConversationTitleLabel: View { + let title: String let contact: ResolvedContact? let conversationID: ConversationID? @@ -314,6 +387,7 @@ private struct ConversationTitleItem: View { imageData: contact?.imageData, size: 44 ) + .accessibilityHidden(true) Text(title) .font(.appBarButton) .foregroundStyle(Color.textMain)