Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions Flipcash/Core/Screens/Conversation/ContactCardView.swift
Original file line number Diff line number Diff line change
@@ -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) {}
}
78 changes: 76 additions & 2 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import SwiftUI
import UIKit
import Combine
import Contacts
import ContactsUI
import FlipcashCore
import FlipcashUI

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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?
Expand All @@ -314,6 +387,7 @@ private struct ConversationTitleItem: View {
imageData: contact?.imageData,
size: 44
)
.accessibilityHidden(true)
Text(title)
.font(.appBarButton)
.foregroundStyle(Color.textMain)
Expand Down