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 widgetWatchSize: CGFloat = 100
|
||||
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 {
|
||||
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 card = Color(red: 1.0, green: 1.0, blue: 1.0)
|
||||
static let accent = Color(red: 0.95, green: 0.91, blue: 0.86)
|
||||
}
|
||||
|
||||
|
||||
@ -75,6 +75,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Add note" : {
|
||||
|
||||
},
|
||||
"Add tag" : {
|
||||
|
||||
},
|
||||
"Address" : {
|
||||
|
||||
@ -151,6 +157,9 @@
|
||||
},
|
||||
"Company Website" : {
|
||||
|
||||
},
|
||||
"Connection details" : {
|
||||
|
||||
},
|
||||
"Contact" : {
|
||||
|
||||
@ -304,12 +313,18 @@
|
||||
},
|
||||
"Messaging" : {
|
||||
|
||||
},
|
||||
"More..." : {
|
||||
|
||||
},
|
||||
"No card selected" : {
|
||||
|
||||
},
|
||||
"No contacts yet" : {
|
||||
|
||||
},
|
||||
"Notes" : {
|
||||
|
||||
},
|
||||
"Open on Apple Watch" : {
|
||||
"localizations" : {
|
||||
@ -663,6 +678,9 @@
|
||||
},
|
||||
"Work" : {
|
||||
|
||||
},
|
||||
"Write down a memorable reminder about your contact" : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
|
||||
@ -84,7 +84,8 @@ final class ContactsStore: ContactTracking {
|
||||
phone: String = "",
|
||||
notes: String = "",
|
||||
tags: String = "",
|
||||
metAt: String = ""
|
||||
metAt: String = "",
|
||||
followUpDate: Date? = nil
|
||||
) {
|
||||
let contact = Contact(
|
||||
name: name,
|
||||
@ -93,6 +94,7 @@ final class ContactsStore: ContactTracking {
|
||||
cardLabel: "Manual",
|
||||
notes: notes,
|
||||
tags: tags,
|
||||
followUpDate: followUpDate,
|
||||
email: email,
|
||||
phone: phone,
|
||||
metAt: metAt
|
||||
|
||||
@ -8,127 +8,171 @@ struct ContactDetailView: View {
|
||||
|
||||
@Bindable var contact: Contact
|
||||
|
||||
@State private var isEditing = 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 {
|
||||
List {
|
||||
// Header section
|
||||
Section {
|
||||
ContactHeaderView(contact: contact)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
// Contact info section
|
||||
if !contact.email.isEmpty || !contact.phone.isEmpty {
|
||||
Section(String.localized("Contact")) {
|
||||
if !contact.email.isEmpty {
|
||||
ContactInfoRow(
|
||||
title: String.localized("Email"),
|
||||
value: contact.email,
|
||||
systemImage: "envelope",
|
||||
action: { openURL("mailto:\(contact.email)") }
|
||||
)
|
||||
}
|
||||
if !contact.phone.isEmpty {
|
||||
ContactInfoRow(
|
||||
title: String.localized("Phone"),
|
||||
value: contact.phone,
|
||||
systemImage: "phone",
|
||||
action: { openURL("tel:\(contact.phone)") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
ZStack(alignment: .bottom) {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// Header banner
|
||||
ContactBannerView(contact: contact)
|
||||
|
||||
// Content
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.large) {
|
||||
// Name
|
||||
Text(contact.name.isEmpty ? String.localized("Contact") : contact.name)
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
|
||||
// Connection details
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||
Text("Connection details")
|
||||
.font(.subheadline)
|
||||
.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())
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Follow-up section
|
||||
Section(String.localized("Follow-up")) {
|
||||
Toggle(String.localized("Set Reminder"), isOn: Binding(
|
||||
get: { contact.followUpDate != nil },
|
||||
set: { newValue in
|
||||
if newValue {
|
||||
contact.followUpDate = .now.addingTimeInterval(86400 * 7) // 1 week default
|
||||
} else {
|
||||
contact.followUpDate = nil
|
||||
|
||||
// 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")
|
||||
.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)
|
||||
}
|
||||
))
|
||||
|
||||
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)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.top, Design.Spacing.large)
|
||||
}
|
||||
}
|
||||
.background(Color.AppBackground.secondary)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
|
||||
// Met at section
|
||||
Section(String.localized("Where You Met")) {
|
||||
TextField(String.localized("Event, location, or how you connected..."), text: $contact.metAt)
|
||||
}
|
||||
|
||||
// Activity section
|
||||
Section(String.localized("Activity")) {
|
||||
LabeledContent(String.localized("Last Shared")) {
|
||||
Text(appState.contactsStore.relativeShareDate(for: contact))
|
||||
}
|
||||
|
||||
LabeledContent(String.localized("Card Used")) {
|
||||
Text(String.localized(contact.cardLabel))
|
||||
}
|
||||
|
||||
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
|
||||
// Bottom action bar
|
||||
BottomActionBar(
|
||||
onMore: { showingMoreActions = true },
|
||||
onAddTag: { showingAddTag = true },
|
||||
onAddNote: { showingAddNote = true }
|
||||
)
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
// Edit action - could navigate to edit mode
|
||||
} label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.foregroundStyle(Color.AppText.inverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(contact.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.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) {
|
||||
@ -138,98 +182,270 @@ 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(isPresented: $showingAddNote) {
|
||||
AddNoteSheet(notes: $contact.notes)
|
||||
}
|
||||
}
|
||||
|
||||
private func openURL(_ urlString: String) {
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContactHeaderView: View {
|
||||
let contact: Contact
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Design.Spacing.medium) {
|
||||
if let photoData = contact.photoData, let uiImage = UIImage(data: photoData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: Design.CardSize.qrSize / 2, height: Design.CardSize.qrSize / 2)
|
||||
.clipShape(.circle)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
private func addTag() {
|
||||
let tag = newTag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !tag.isEmpty else { return }
|
||||
|
||||
if contact.tags.isEmpty {
|
||||
contact.tags = tag
|
||||
} else {
|
||||
contact.tags += ", \(tag)"
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Design.Spacing.medium)
|
||||
newTag = ""
|
||||
}
|
||||
|
||||
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 {
|
||||
let title: String
|
||||
let icon: String
|
||||
let value: String
|
||||
let systemImage: String
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Design.Spacing.medium) {
|
||||
Image(systemName: systemImage)
|
||||
.foregroundStyle(Color.Accent.red)
|
||||
.frame(width: Design.Spacing.xLarge)
|
||||
// Icon circle
|
||||
Image(systemName: icon)
|
||||
.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) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
Text(value)
|
||||
.font(.body)
|
||||
.foregroundStyle(Color.Text.primary)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.Text.tertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.Text.secondary)
|
||||
}
|
||||
.padding(Design.Spacing.medium)
|
||||
}
|
||||
.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 {
|
||||
NavigationStack {
|
||||
ContactDetailView(
|
||||
contact: Contact(
|
||||
name: "Kevin Lennox",
|
||||
role: "Branch Manager",
|
||||
company: "Global Bank",
|
||||
notes: "Met at the Austin fintech conference",
|
||||
tags: "finance, potential client",
|
||||
followUpDate: .now.addingTimeInterval(86400 * 3),
|
||||
email: "kevin@globalbank.com",
|
||||
phone: "+1 555 123 4567",
|
||||
metAt: "Austin Fintech Conference 2026"
|
||||
name: "Heidi Bruce",
|
||||
role: "Designer",
|
||||
company: "Apple",
|
||||
notes: "",
|
||||
tags: "",
|
||||
email: "heidi.h.bruce@gmail.com",
|
||||
phone: "2145328862"
|
||||
)
|
||||
)
|
||||
.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
|
||||
if !appState.cardStore.cards.isEmpty {
|
||||
// Floating share button - only shown on cards tab when cards exist
|
||||
if appState.selectedTab == .cards && !appState.cardStore.cards.isEmpty {
|
||||
FloatingShareButton {
|
||||
showingShareSheet = true
|
||||
}
|
||||
@ -62,7 +62,7 @@ private struct FloatingShareButton: View {
|
||||
}
|
||||
.accessibilityLabel(String.localized("Share"))
|
||||
.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(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name = ""
|
||||
@State private var role = ""
|
||||
@State private var company = ""
|
||||
@State private var email = ""
|
||||
@State private var firstName = ""
|
||||
@State private var lastName = ""
|
||||
@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 {
|
||||
!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 {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(String.localized("Name")) {
|
||||
TextField(String.localized("Full name"), text: $name)
|
||||
.textContentType(.name)
|
||||
.accessibilityLabel(String.localized("Contact name"))
|
||||
}
|
||||
|
||||
Section(String.localized("Role")) {
|
||||
TextField(String.localized("Job title"), text: $role)
|
||||
.textContentType(.jobTitle)
|
||||
TextField(String.localized("Company"), text: $company)
|
||||
.textContentType(.organizationName)
|
||||
}
|
||||
|
||||
Section(String.localized("Contact")) {
|
||||
TextField(String.localized("Email"), text: $email)
|
||||
.keyboardType(.emailAddress)
|
||||
.textContentType(.emailAddress)
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField(String.localized("Phone"), text: $phone)
|
||||
.keyboardType(.phonePad)
|
||||
.textContentType(.telephoneNumber)
|
||||
}
|
||||
|
||||
Section(String.localized("Where You Met")) {
|
||||
TextField(String.localized("Event, location, or how you connected..."), text: $metAt)
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// Name section
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
EditorTextField(placeholder: String.localized("First name"), text: $firstName)
|
||||
.textContentType(.givenName)
|
||||
EditorTextField(placeholder: String.localized("Last name"), text: $lastName)
|
||||
.textContentType(.familyName)
|
||||
}
|
||||
.padding(Design.Spacing.large)
|
||||
.background(Color.AppBackground.elevated)
|
||||
|
||||
// Contact section
|
||||
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||
EditorTextField(placeholder: String.localized("Phone"), text: $phone)
|
||||
.keyboardType(.phonePad)
|
||||
.textContentType(.telephoneNumber)
|
||||
EditorTextField(placeholder: String.localized("Email"), text: $email)
|
||||
.keyboardType(.emailAddress)
|
||||
.textContentType(.emailAddress)
|
||||
.textInputAutocapitalization(.never)
|
||||
EditorTextField(placeholder: String.localized("Link"), text: $link)
|
||||
.keyboardType(.URL)
|
||||
.textContentType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
.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)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@ -59,7 +81,6 @@ struct AddContactSheet: View {
|
||||
Button(String.localized("Save")) {
|
||||
saveContact()
|
||||
}
|
||||
.bold()
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
@ -68,17 +89,30 @@ struct AddContactSheet: View {
|
||||
|
||||
private func saveContact() {
|
||||
appState.contactsStore.createContact(
|
||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
role: role.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
name: fullName,
|
||||
role: jobTitle.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
company: company.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
phone: phone.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
metAt: metAt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
phone: phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
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 {
|
||||
AddContactSheet()
|
||||
.environment(AppState(modelContext: try! ModelContainer(for: BusinessCard.self, Contact.self).mainContext))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user