BusinessCard/BusinessCard/Views/Components/AddedContactFieldsView.swift

210 lines
8.0 KiB
Swift

import SwiftUI
import Bedrock
/// Represents a contact field that has been added
struct AddedContactField: Identifiable, Equatable {
let id: UUID
let fieldType: ContactFieldType
var value: String
var title: String
init(id: UUID = UUID(), fieldType: ContactFieldType, value: String = "", title: String = "") {
self.id = id
self.fieldType = fieldType
self.value = value
self.title = title
}
static func == (lhs: AddedContactField, rhs: AddedContactField) -> Bool {
lhs.id == rhs.id && lhs.value == rhs.value && lhs.title == rhs.title
}
/// Returns the display value for this field (formatted for addresses, raw for others)
var displayValue: String {
fieldType.formattedDisplayValue(value)
}
/// Returns a short display value suitable for single-line display in lists
var shortDisplayValue: String {
if fieldType.id == "address" {
// For addresses, show single-line format in the list
if let address = PostalAddress.decode(from: value), address.hasValue {
return address.singleLineString
}
}
return value
}
}
/// Displays a vertical list of added contact fields with tap to edit and drag to reorder
struct AddedContactFieldsView: View {
@Binding var fields: [AddedContactField]
var themeColor: Color = Color(red: 0.2, green: 0.2, blue: 0.2)
let onEdit: (AddedContactField) -> Void
@State private var draggingField: AddedContactField?
var body: some View {
if fields.isEmpty {
EmptyView()
} else {
VStack(spacing: 0) {
ForEach(fields) { field in
FieldRow(
field: field,
themeColor: themeColor,
onTap: { onEdit(field) },
onDelete: { deleteField(field) }
)
.draggable(field.id.uuidString) {
// Drag preview
FieldRowPreview(field: field, themeColor: themeColor)
}
.dropDestination(for: String.self) { items, _ in
guard let droppedId = items.first,
let droppedUUID = UUID(uuidString: droppedId),
let fromIndex = fields.firstIndex(where: { $0.id == droppedUUID }),
let toIndex = fields.firstIndex(where: { $0.id == field.id }),
fromIndex != toIndex else {
return false
}
withAnimation(.spring(duration: Design.Animation.quick)) {
let movedField = fields.remove(at: fromIndex)
fields.insert(movedField, at: toIndex)
}
return true
}
if field.id != fields.last?.id {
Divider()
.padding(.leading, Design.CardSize.avatarSize + Design.Spacing.large + Design.Spacing.medium)
}
}
}
.background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
}
}
private func deleteField(_ field: AddedContactField) {
withAnimation {
fields.removeAll { $0.id == field.id }
}
}
}
/// Preview shown while dragging a field
private struct FieldRowPreview: View {
let field: AddedContactField
let themeColor: Color
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Circle()
.fill(themeColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
field.fieldType.iconImage()
.typography(.title3)
.foregroundStyle(.white)
)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(field.value.isEmpty ? field.fieldType.displayName : field.shortDisplayValue)
.typography(.subheading)
.foregroundStyle(Color.Text.primary)
.lineLimit(1)
if !field.title.isEmpty {
Text(field.title)
.typography(.caption)
.foregroundStyle(Color.Text.secondary)
.lineLimit(1)
}
}
}
.padding(Design.Spacing.medium)
.background(Color.AppBackground.elevated)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.shadow(radius: Design.Shadow.radiusMedium)
}
}
/// A display row for a contact field - tap to edit, hold to drag
private struct FieldRow: View {
let field: AddedContactField
let themeColor: Color
let onTap: () -> Void
let onDelete: () -> Void
var body: some View {
HStack(spacing: Design.Spacing.medium) {
// Drag handle
Image(systemName: "line.3.horizontal")
.typography(.caption)
.foregroundStyle(Color.Text.tertiary)
.frame(width: Design.Spacing.large)
// Icon
Circle()
.fill(themeColor)
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay(
field.fieldType.iconImage()
.typography(.title3)
.foregroundStyle(.white)
)
// Content - tap to edit
Button(action: onTap) {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(field.value.isEmpty ? field.fieldType.valuePlaceholder : field.shortDisplayValue)
.typography(.subheading)
.foregroundStyle(field.value.isEmpty ? Color.Text.secondary : Color.Text.primary)
.lineLimit(1)
Text(field.title.isEmpty ? field.fieldType.displayName : field.title)
.typography(.caption)
.foregroundStyle(Color.Text.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
// Delete button
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.typography(.title3)
.foregroundStyle(Color.Text.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "Delete"))
.accessibilityHint(String(localized: "Removes this field"))
}
.padding(Design.Spacing.medium)
.contentShape(.rect)
}
}
#Preview {
@Previewable @State var fields: [AddedContactField] = {
let address = PostalAddress(street: "6565 Headquarters Dr", city: "Plano", state: "TX", postalCode: "75024")
return [
AddedContactField(fieldType: .email, value: "matt@example.com", title: "Work"),
AddedContactField(fieldType: .email, value: "personal@example.com", title: "Personal"),
AddedContactField(fieldType: .phone, value: "+1 (555) 123-4567", title: "Cell"),
AddedContactField(fieldType: .address, value: address.encode(), title: "Work"),
AddedContactField(fieldType: .linkedIn, value: "linkedin.com/in/mattbruce", title: "Connect with me")
]
}()
ScrollView {
AddedContactFieldsView(fields: $fields) { field in
Design.debugLog("Edit: \(field.fieldType.displayName)")
}
.padding()
}
.background(Color.AppBackground.base)
}