Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
5e2ff8b990
commit
6ee74c422b
@ -29,6 +29,8 @@ extension Design {
|
|||||||
static let widgetPhoneHeight: CGFloat = 120
|
static let widgetPhoneHeight: CGFloat = 120
|
||||||
static let widgetWatchSize: CGFloat = 100
|
static let widgetWatchSize: CGFloat = 100
|
||||||
static let floatingButtonSize: CGFloat = 56
|
static let floatingButtonSize: CGFloat = 56
|
||||||
|
/// Bottom offset for floating button above tab bar.
|
||||||
|
static let floatingButtonBottomOffset: CGFloat = 72
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,7 +51,9 @@ extension Color {
|
|||||||
|
|
||||||
enum AppBackground {
|
enum AppBackground {
|
||||||
static let base = Color(red: 0.97, green: 0.96, blue: 0.94)
|
static let base = Color(red: 0.97, green: 0.96, blue: 0.94)
|
||||||
|
static let secondary = Color(red: 0.95, green: 0.95, blue: 0.95)
|
||||||
static let elevated = Color(red: 1.0, green: 1.0, blue: 1.0)
|
static let elevated = Color(red: 1.0, green: 1.0, blue: 1.0)
|
||||||
|
static let card = Color(red: 1.0, green: 1.0, blue: 1.0)
|
||||||
static let accent = Color(red: 0.95, green: 0.91, blue: 0.86)
|
static let accent = Color(red: 0.95, green: 0.91, blue: 0.86)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -75,6 +75,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Add note" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Add tag" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Address" : {
|
"Address" : {
|
||||||
|
|
||||||
@ -151,6 +157,9 @@
|
|||||||
},
|
},
|
||||||
"Company Website" : {
|
"Company Website" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Connection details" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Contact" : {
|
"Contact" : {
|
||||||
|
|
||||||
@ -304,12 +313,18 @@
|
|||||||
},
|
},
|
||||||
"Messaging" : {
|
"Messaging" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"More..." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No card selected" : {
|
"No card selected" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No contacts yet" : {
|
"No contacts yet" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Notes" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Open on Apple Watch" : {
|
"Open on Apple Watch" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -663,6 +678,9 @@
|
|||||||
},
|
},
|
||||||
"Work" : {
|
"Work" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Write down a memorable reminder about your contact" : {
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
@ -84,7 +84,8 @@ final class ContactsStore: ContactTracking {
|
|||||||
phone: String = "",
|
phone: String = "",
|
||||||
notes: String = "",
|
notes: String = "",
|
||||||
tags: String = "",
|
tags: String = "",
|
||||||
metAt: String = ""
|
metAt: String = "",
|
||||||
|
followUpDate: Date? = nil
|
||||||
) {
|
) {
|
||||||
let contact = Contact(
|
let contact = Contact(
|
||||||
name: name,
|
name: name,
|
||||||
@ -93,6 +94,7 @@ final class ContactsStore: ContactTracking {
|
|||||||
cardLabel: "Manual",
|
cardLabel: "Manual",
|
||||||
notes: notes,
|
notes: notes,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
|
followUpDate: followUpDate,
|
||||||
email: email,
|
email: email,
|
||||||
phone: phone,
|
phone: phone,
|
||||||
metAt: metAt
|
metAt: metAt
|
||||||
|
|||||||
@ -8,127 +8,171 @@ struct ContactDetailView: View {
|
|||||||
|
|
||||||
@Bindable var contact: Contact
|
@Bindable var contact: Contact
|
||||||
|
|
||||||
@State private var isEditing = false
|
|
||||||
@State private var showingDeleteConfirmation = false
|
@State private var showingDeleteConfirmation = false
|
||||||
|
@State private var showingMoreActions = false
|
||||||
|
@State private var showingAddTag = false
|
||||||
|
@State private var showingAddNote = false
|
||||||
|
@State private var newTag = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
ZStack(alignment: .bottom) {
|
||||||
// Header section
|
ScrollView {
|
||||||
Section {
|
VStack(spacing: 0) {
|
||||||
ContactHeaderView(contact: contact)
|
// Header banner
|
||||||
}
|
ContactBannerView(contact: contact)
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
|
// Content
|
||||||
// Contact info section
|
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||||
if !contact.email.isEmpty || !contact.phone.isEmpty {
|
// Name
|
||||||
Section(String.localized("Contact")) {
|
Text(contact.name.isEmpty ? String.localized("Contact") : contact.name)
|
||||||
if !contact.email.isEmpty {
|
.font(.largeTitle)
|
||||||
ContactInfoRow(
|
.bold()
|
||||||
title: String.localized("Email"),
|
.foregroundStyle(Color.Text.primary)
|
||||||
value: contact.email,
|
|
||||||
systemImage: "envelope",
|
// Connection details
|
||||||
action: { openURL("mailto:\(contact.email)") }
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
)
|
Text("Connection details")
|
||||||
}
|
.font(.subheadline)
|
||||||
if !contact.phone.isEmpty {
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
ContactInfoRow(
|
|
||||||
title: String.localized("Phone"),
|
HStack(spacing: Design.Spacing.small) {
|
||||||
value: contact.phone,
|
Image(systemName: "calendar")
|
||||||
systemImage: "phone",
|
.foregroundStyle(Color.Text.primary)
|
||||||
action: { openURL("tel:\(contact.phone)") }
|
Text(contact.lastSharedDate, format: .dateTime.day().month().year().hour().minute())
|
||||||
)
|
.font(.subheadline)
|
||||||
}
|
.foregroundStyle(Color.Text.primary)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notes section
|
|
||||||
Section(String.localized("Notes")) {
|
|
||||||
TextField(String.localized("Add notes about this contact..."), text: $contact.notes, axis: .vertical)
|
|
||||||
.lineLimit(3...10)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags section
|
|
||||||
Section(String.localized("Tags")) {
|
|
||||||
TextField(String.localized("Tags (comma separated)"), text: $contact.tags)
|
|
||||||
.accessibilityHint(String.localized("e.g. client, VIP, networking"))
|
|
||||||
|
|
||||||
if !contact.tagList.isEmpty {
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: Design.Spacing.small) {
|
|
||||||
ForEach(contact.tagList, id: \.self) { tag in
|
|
||||||
Text(tag)
|
|
||||||
.font(.caption)
|
|
||||||
.padding(.horizontal, Design.Spacing.small)
|
|
||||||
.padding(.vertical, Design.Spacing.xSmall)
|
|
||||||
.background(Color.Accent.red.opacity(Design.Opacity.hint))
|
|
||||||
.foregroundStyle(Color.Accent.red)
|
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
// Tags
|
||||||
}
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
ForEach(contact.tagList, id: \.self) { tag in
|
||||||
// Follow-up section
|
TagPill(text: tag, onDelete: {
|
||||||
Section(String.localized("Follow-up")) {
|
removeTag(tag)
|
||||||
Toggle(String.localized("Set Reminder"), isOn: Binding(
|
})
|
||||||
get: { contact.followUpDate != nil },
|
}
|
||||||
set: { newValue in
|
|
||||||
if newValue {
|
Button {
|
||||||
contact.followUpDate = .now.addingTimeInterval(86400 * 7) // 1 week default
|
showingAddTag = true
|
||||||
} else {
|
} label: {
|
||||||
contact.followUpDate = nil
|
Label(String.localized("Add tag"), systemImage: "plus")
|
||||||
|
.font(.subheadline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.background(Color.Text.primary)
|
||||||
|
.clipShape(.capsule)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contact info card
|
||||||
|
if !contact.email.isEmpty || !contact.phone.isEmpty {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if !contact.email.isEmpty {
|
||||||
|
ContactInfoRow(
|
||||||
|
icon: "envelope.fill",
|
||||||
|
value: contact.email,
|
||||||
|
label: "Home",
|
||||||
|
action: { openURL("mailto:\(contact.email)") }
|
||||||
|
)
|
||||||
|
if !contact.phone.isEmpty {
|
||||||
|
Divider()
|
||||||
|
.padding(.leading, Design.Spacing.xLarge + Design.CardSize.avatarSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !contact.phone.isEmpty {
|
||||||
|
ContactInfoRow(
|
||||||
|
icon: "phone.fill",
|
||||||
|
value: contact.phone,
|
||||||
|
label: "Cell",
|
||||||
|
action: { openURL("tel:\(contact.phone)") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.AppBackground.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes section
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
Text("Notes")
|
||||||
|
.font(.headline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
|
||||||
|
if contact.notes.isEmpty {
|
||||||
|
NotesEmptyState()
|
||||||
|
} else {
|
||||||
|
Text(contact.notes)
|
||||||
|
.font(.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")
|
||||||
|
.font(.subheadline)
|
||||||
|
.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)
|
||||||
if let followUpDate = contact.followUpDate {
|
|
||||||
DatePicker(
|
|
||||||
String.localized("Reminder Date"),
|
|
||||||
selection: Binding(
|
|
||||||
get: { followUpDate },
|
|
||||||
set: { contact.followUpDate = $0 }
|
|
||||||
),
|
|
||||||
displayedComponents: .date
|
|
||||||
)
|
|
||||||
|
|
||||||
if contact.isFollowUpOverdue {
|
|
||||||
Label(String.localized("Overdue"), systemImage: "exclamationmark.circle.fill")
|
|
||||||
.foregroundStyle(Color.Accent.red)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.background(Color.AppBackground.secondary)
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
|
||||||
// Met at section
|
// Bottom action bar
|
||||||
Section(String.localized("Where You Met")) {
|
BottomActionBar(
|
||||||
TextField(String.localized("Event, location, or how you connected..."), text: $contact.metAt)
|
onMore: { showingMoreActions = true },
|
||||||
}
|
onAddTag: { showingAddTag = true },
|
||||||
|
onAddNote: { showingAddNote = true }
|
||||||
// Activity section
|
)
|
||||||
Section(String.localized("Activity")) {
|
}
|
||||||
LabeledContent(String.localized("Last Shared")) {
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
Text(appState.contactsStore.relativeShareDate(for: contact))
|
.toolbar {
|
||||||
}
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button {
|
||||||
LabeledContent(String.localized("Card Used")) {
|
// Edit action - could navigate to edit mode
|
||||||
Text(String.localized(contact.cardLabel))
|
} label: {
|
||||||
}
|
Image(systemName: "square.and.pencil")
|
||||||
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
if contact.isReceivedCard {
|
|
||||||
Label(String.localized("Received via QR scan"), systemImage: "qrcode")
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete section
|
|
||||||
Section {
|
|
||||||
Button(String.localized("Delete Contact"), role: .destructive) {
|
|
||||||
showingDeleteConfirmation = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(contact.name)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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) {
|
||||||
@ -138,98 +182,270 @@ 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) {
|
||||||
|
TextField(String.localized("Tag name"), text: $newTag)
|
||||||
|
Button(String.localized("Cancel"), role: .cancel) {
|
||||||
|
newTag = ""
|
||||||
|
}
|
||||||
|
Button(String.localized("Add")) {
|
||||||
|
addTag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingAddNote) {
|
||||||
|
AddNoteSheet(notes: $contact.notes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openURL(_ urlString: String) {
|
private func openURL(_ urlString: String) {
|
||||||
guard let url = URL(string: urlString) else { return }
|
guard let url = URL(string: urlString) else { return }
|
||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private struct ContactHeaderView: View {
|
|
||||||
let contact: Contact
|
|
||||||
|
|
||||||
var body: some View {
|
private func addTag() {
|
||||||
VStack(spacing: Design.Spacing.medium) {
|
let tag = newTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let photoData = contact.photoData, let uiImage = UIImage(data: photoData) {
|
guard !tag.isEmpty else { return }
|
||||||
Image(uiImage: uiImage)
|
|
||||||
.resizable()
|
if contact.tags.isEmpty {
|
||||||
.scaledToFill()
|
contact.tags = tag
|
||||||
.frame(width: Design.CardSize.qrSize / 2, height: Design.CardSize.qrSize / 2)
|
} else {
|
||||||
.clipShape(.circle)
|
contact.tags += ", \(tag)"
|
||||||
} else {
|
|
||||||
Image(systemName: contact.avatarSystemName)
|
|
||||||
.font(.system(size: Design.BaseFontSize.display))
|
|
||||||
.foregroundStyle(Color.Accent.red)
|
|
||||||
.frame(width: Design.CardSize.qrSize / 2, height: Design.CardSize.qrSize / 2)
|
|
||||||
.background(Color.AppBackground.accent)
|
|
||||||
.clipShape(.circle)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: Design.Spacing.xSmall) {
|
|
||||||
Text(contact.name)
|
|
||||||
.font(.title2)
|
|
||||||
.bold()
|
|
||||||
.foregroundStyle(Color.Text.primary)
|
|
||||||
|
|
||||||
if !contact.role.isEmpty || !contact.company.isEmpty {
|
|
||||||
Text("\(contact.role)\(contact.role.isEmpty || contact.company.isEmpty ? "" : " at ")\(contact.company)")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
newTag = ""
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
}
|
||||||
|
|
||||||
|
private func removeTag(_ tag: String) {
|
||||||
|
var tags = contact.tagList
|
||||||
|
tags.removeAll { $0 == tag }
|
||||||
|
contact.tags = tags.joined(separator: ", ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Banner View
|
||||||
|
|
||||||
|
private struct ContactBannerView: View {
|
||||||
|
let contact: Contact
|
||||||
|
|
||||||
|
private var initials: String {
|
||||||
|
let parts = contact.name.split(separator: " ")
|
||||||
|
if parts.count >= 2 {
|
||||||
|
return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
|
||||||
|
} else if let first = parts.first {
|
||||||
|
return String(first.prefix(2)).uppercased()
|
||||||
|
}
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// 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)))
|
||||||
|
.font(.system(size: Design.BaseFontSize.display, weight: .light))
|
||||||
|
Text(String(initials.dropFirst().prefix(1)))
|
||||||
|
.font(.system(size: Design.BaseFontSize.display, weight: .light))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.white.opacity(Design.Opacity.accent))
|
||||||
|
}
|
||||||
|
.frame(height: Design.CardSize.bannerHeight * 1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tag Pill
|
||||||
|
|
||||||
|
private struct TagPill: View {
|
||||||
|
let text: String
|
||||||
|
let onDelete: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Text(text)
|
||||||
|
.font(.subheadline)
|
||||||
|
Button {
|
||||||
|
onDelete()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.background(Color.AppBackground.card)
|
||||||
|
.clipShape(.capsule)
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.stroke(Color.Text.tertiary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Contact Info Row
|
||||||
|
|
||||||
private struct ContactInfoRow: View {
|
private struct ContactInfoRow: View {
|
||||||
let title: String
|
let icon: String
|
||||||
let value: String
|
let value: String
|
||||||
let systemImage: String
|
let label: String
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack(spacing: Design.Spacing.medium) {
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
Image(systemName: systemImage)
|
// Icon circle
|
||||||
.foregroundStyle(Color.Accent.red)
|
Image(systemName: icon)
|
||||||
.frame(width: Design.Spacing.xLarge)
|
.font(.body)
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
|
||||||
|
.background(Color.CardPalette.coral)
|
||||||
|
.clipShape(.circle)
|
||||||
|
|
||||||
|
// Text
|
||||||
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
Text(title)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(Color.Text.primary)
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.Text.secondary)
|
|
||||||
}
|
}
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Notes Empty State
|
||||||
|
|
||||||
|
private struct NotesEmptyState: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: "note.text")
|
||||||
|
.font(.system(size: Design.BaseFontSize.display))
|
||||||
|
.foregroundStyle(Color.CardPalette.coral.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
|
Text("Write down a memorable reminder about your contact")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.Text.tertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, Design.Spacing.xLarge)
|
||||||
|
.background(Color.AppBackground.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bottom Action Bar
|
||||||
|
|
||||||
|
private struct BottomActionBar: View {
|
||||||
|
let onMore: () -> Void
|
||||||
|
let onAddTag: () -> Void
|
||||||
|
let onAddNote: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Button(action: onMore) {
|
||||||
|
Text("More...")
|
||||||
|
.font(.subheadline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.Text.primary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
.background(Color.AppBackground.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: onAddTag) {
|
||||||
|
Text("Add tag")
|
||||||
|
.font(.subheadline)
|
||||||
|
.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")
|
||||||
|
.font(.subheadline)
|
||||||
|
.bold()
|
||||||
|
.foregroundStyle(Color.AppText.inverted)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
.background(Color.Text.primary)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
.background(Color.AppBackground.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Add Note Sheet
|
||||||
|
|
||||||
|
private struct AddNoteSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Binding var notes: String
|
||||||
|
@State private var editedNotes: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
TextEditor(text: $editedNotes)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.navigationTitle(String.localized("Notes"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String.localized("Cancel")) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(String.localized("Save")) {
|
||||||
|
notes = editedNotes
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
editedNotes = notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ContactDetailView(
|
ContactDetailView(
|
||||||
contact: Contact(
|
contact: Contact(
|
||||||
name: "Kevin Lennox",
|
name: "Heidi Bruce",
|
||||||
role: "Branch Manager",
|
role: "Designer",
|
||||||
company: "Global Bank",
|
company: "Apple",
|
||||||
notes: "Met at the Austin fintech conference",
|
notes: "",
|
||||||
tags: "finance, potential client",
|
tags: "",
|
||||||
followUpDate: .now.addingTimeInterval(86400 * 3),
|
email: "heidi.h.bruce@gmail.com",
|
||||||
email: "kevin@globalbank.com",
|
phone: "2145328862"
|
||||||
phone: "+1 555 123 4567",
|
|
||||||
metAt: "Austin Fintech Conference 2026"
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||||
|
|||||||
@ -24,8 +24,8 @@ struct RootTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating share button - hidden when no cards exist
|
// Floating share button - only shown on cards tab when cards exist
|
||||||
if !appState.cardStore.cards.isEmpty {
|
if appState.selectedTab == .cards && !appState.cardStore.cards.isEmpty {
|
||||||
FloatingShareButton {
|
FloatingShareButton {
|
||||||
showingShareSheet = true
|
showingShareSheet = true
|
||||||
}
|
}
|
||||||
@ -62,7 +62,7 @@ private struct FloatingShareButton: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel(String.localized("Share"))
|
.accessibilityLabel(String.localized("Share"))
|
||||||
.accessibilityHint(String.localized("Opens the share sheet to send your card"))
|
.accessibilityHint(String.localized("Opens the share sheet to send your card"))
|
||||||
.padding(.bottom, Design.Spacing.xxLarge + Design.Spacing.xLarge)
|
.padding(.bottom, Design.CardSize.floatingButtonBottomOffset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,48 +6,70 @@ 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
|
||||||
|
|
||||||
@State private var name = ""
|
@State private var firstName = ""
|
||||||
@State private var role = ""
|
@State private var lastName = ""
|
||||||
@State private var company = ""
|
|
||||||
@State private var email = ""
|
|
||||||
@State private var phone = ""
|
@State private var phone = ""
|
||||||
@State private var metAt = ""
|
@State private var email = ""
|
||||||
|
@State private var link = ""
|
||||||
|
@State private var jobTitle = ""
|
||||||
|
@State private var company = ""
|
||||||
|
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
!firstName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
||||||
|
!lastName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fullName: String {
|
||||||
|
[firstName, lastName]
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
ScrollView {
|
||||||
Section(String.localized("Name")) {
|
VStack(spacing: 0) {
|
||||||
TextField(String.localized("Full name"), text: $name)
|
// Name section
|
||||||
.textContentType(.name)
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
.accessibilityLabel(String.localized("Contact name"))
|
EditorTextField(placeholder: String.localized("First name"), text: $firstName)
|
||||||
}
|
.textContentType(.givenName)
|
||||||
|
EditorTextField(placeholder: String.localized("Last name"), text: $lastName)
|
||||||
Section(String.localized("Role")) {
|
.textContentType(.familyName)
|
||||||
TextField(String.localized("Job title"), text: $role)
|
}
|
||||||
.textContentType(.jobTitle)
|
.padding(Design.Spacing.large)
|
||||||
TextField(String.localized("Company"), text: $company)
|
.background(Color.AppBackground.elevated)
|
||||||
.textContentType(.organizationName)
|
|
||||||
}
|
// Contact section
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
Section(String.localized("Contact")) {
|
EditorTextField(placeholder: String.localized("Phone"), text: $phone)
|
||||||
TextField(String.localized("Email"), text: $email)
|
.keyboardType(.phonePad)
|
||||||
.keyboardType(.emailAddress)
|
.textContentType(.telephoneNumber)
|
||||||
.textContentType(.emailAddress)
|
EditorTextField(placeholder: String.localized("Email"), text: $email)
|
||||||
.textInputAutocapitalization(.never)
|
.keyboardType(.emailAddress)
|
||||||
TextField(String.localized("Phone"), text: $phone)
|
.textContentType(.emailAddress)
|
||||||
.keyboardType(.phonePad)
|
.textInputAutocapitalization(.never)
|
||||||
.textContentType(.telephoneNumber)
|
EditorTextField(placeholder: String.localized("Link"), text: $link)
|
||||||
}
|
.keyboardType(.URL)
|
||||||
|
.textContentType(.URL)
|
||||||
Section(String.localized("Where You Met")) {
|
.textInputAutocapitalization(.never)
|
||||||
TextField(String.localized("Event, location, or how you connected..."), text: $metAt)
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
|
|
||||||
|
// Professional section
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
EditorTextField(placeholder: String.localized("Job title"), text: $jobTitle)
|
||||||
|
.textContentType(.jobTitle)
|
||||||
|
EditorTextField(placeholder: String.localized("Company"), text: $company)
|
||||||
|
.textContentType(.organizationName)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(Color.AppBackground.elevated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(String.localized("Add Contact"))
|
.background(Color.AppBackground.base)
|
||||||
|
.navigationTitle(String.localized("New contact"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
@ -59,7 +81,6 @@ struct AddContactSheet: View {
|
|||||||
Button(String.localized("Save")) {
|
Button(String.localized("Save")) {
|
||||||
saveContact()
|
saveContact()
|
||||||
}
|
}
|
||||||
.bold()
|
|
||||||
.disabled(!canSave)
|
.disabled(!canSave)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -68,17 +89,30 @@ struct AddContactSheet: View {
|
|||||||
|
|
||||||
private func saveContact() {
|
private func saveContact() {
|
||||||
appState.contactsStore.createContact(
|
appState.contactsStore.createContact(
|
||||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
name: fullName,
|
||||||
role: role.trimmingCharacters(in: .whitespacesAndNewlines),
|
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
|
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
phone: phone.trimmingCharacters(in: .whitespacesAndNewlines),
|
phone: phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
metAt: metAt.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
)
|
)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared Editor TextField
|
||||||
|
|
||||||
|
private struct EditorTextField: View {
|
||||||
|
let placeholder: String
|
||||||
|
@Binding var text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
AddContactSheet()
|
AddContactSheet()
|
||||||
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user