BusinessCard/BusinessCard/Views/Sheets/ContactFieldEditorSheet.swift

319 lines
11 KiB
Swift

import SwiftUI
import Bedrock
/// Sheet for adding or editing a contact field value
struct ContactFieldEditorSheet: View {
@Environment(\.dismiss) private var dismiss
let fieldType: ContactFieldType
let initialValue: String
let initialTitle: String
let themeColor: Color
let onSave: (String, String) -> Void
let onDelete: (() -> Void)?
@State private var value: String
@State private var title: String
@State private var postalAddress: PostalAddress
init(
fieldType: ContactFieldType,
initialValue: String = "",
initialTitle: String = "",
themeColor: Color = Color(red: 0.2, green: 0.2, blue: 0.2),
onSave: @escaping (String, String) -> Void,
onDelete: (() -> Void)? = nil
) {
self.fieldType = fieldType
self.initialValue = initialValue
self.initialTitle = initialTitle
self.themeColor = themeColor
self.onSave = onSave
self.onDelete = onDelete
_value = State(initialValue: initialValue)
_title = State(initialValue: initialTitle)
// Parse postal address if this is an address field
if fieldType.id == "address" {
if let parsed = PostalAddress.decode(from: initialValue), parsed.hasValue {
_postalAddress = State(initialValue: parsed)
} else {
_postalAddress = State(initialValue: PostalAddress())
}
} else {
_postalAddress = State(initialValue: PostalAddress())
}
}
private var isAddressField: Bool {
fieldType.id == "address"
}
private var isValid: Bool {
if isAddressField {
return postalAddress.hasValue
}
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var isEditing: Bool {
!initialValue.isEmpty
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Header with icon
FieldHeaderView(fieldType: fieldType, themeColor: themeColor)
Divider()
// Form content
VStack(alignment: .leading, spacing: Design.Spacing.xLarge) {
// Value field - different for address vs other fields
if isAddressField {
AddressEditorView(address: $postalAddress)
} else {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(fieldType.valueLabel)
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
TextField(fieldType.valuePlaceholder, text: $value)
.keyboardType(fieldType.keyboardType)
.textInputAutocapitalization(fieldType.autocapitalization)
.textContentType(textContentType)
Divider()
}
}
// Title field
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text("Title (optional)")
.font(.subheadline)
.foregroundStyle(Color.Text.primary)
TextField(String(localized: "e.g. Work, Personal"), text: $title)
Divider()
// Suggestions
if !fieldType.titleSuggestions.isEmpty {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text("Here are some suggestions for your title:")
.font(.caption)
.foregroundStyle(Color.Text.secondary)
FlowLayout(spacing: Design.Spacing.small) {
ForEach(fieldType.titleSuggestions, id: \.self) { suggestion in
SuggestionChip(text: suggestion) {
title = suggestion
}
}
}
}
}
}
Spacer()
// Delete button for editing
if isEditing, let onDelete {
Button(role: .destructive) {
onDelete()
dismiss()
} label: {
HStack {
Spacer()
Label("Delete Field", systemImage: "trash")
Spacer()
}
.padding(Design.Spacing.medium)
.background(Color.Accent.red.opacity(Design.Opacity.subtle))
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.buttonStyle(.plain)
.foregroundStyle(Color.Accent.red)
}
}
.padding(Design.Spacing.large)
}
.background(Color.AppBackground.base)
.navigationTitle(isEditing ? "Edit \(fieldType.displayName)" : "Add \(fieldType.displayName)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Image(systemName: "chevron.left")
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
if isAddressField {
// Save postal address as JSON
onSave(postalAddress.encode(), title)
} else {
onSave(value.trimmingCharacters(in: .whitespacesAndNewlines), title)
}
dismiss()
}
.disabled(!isValid)
}
}
}
}
private var textContentType: UITextContentType? {
switch fieldType.id {
case "phone": return .telephoneNumber
case "email": return .emailAddress
case "website": return .URL
case "address": return .fullStreetAddress
default: return .URL
}
}
}
// MARK: - Field Header
private struct FieldHeaderView: View {
let fieldType: ContactFieldType
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(
fieldType.iconImage()
.font(.title3)
.foregroundStyle(.white)
)
Text(fieldType.displayName)
.font(.headline)
.foregroundStyle(Color.Text.primary)
Spacer()
}
.padding(Design.Spacing.large)
.background(Color.AppBackground.elevated)
}
}
// MARK: - Suggestion Chip
private struct SuggestionChip: View {
let text: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(text)
.font(.subheadline)
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.small)
.background(Color.AppBackground.elevated)
.clipShape(.capsule)
.overlay(
Capsule()
.stroke(Color.Text.secondary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
)
}
.buttonStyle(.plain)
}
}
// MARK: - Flow Layout
private struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = layout(subviews: subviews, proposal: proposal)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = layout(subviews: subviews, proposal: proposal)
for (index, position) in result.positions.enumerated() {
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
}
}
private func layout(subviews: Subviews, proposal: ProposedViewSize) -> (size: CGSize, positions: [CGPoint]) {
let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = []
var currentX: CGFloat = 0
var currentY: CGFloat = 0
var lineHeight: CGFloat = 0
var maxX: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentX + size.width > maxWidth && currentX > 0 {
currentX = 0
currentY += lineHeight + spacing
lineHeight = 0
}
positions.append(CGPoint(x: currentX, y: currentY))
lineHeight = max(lineHeight, size.height)
currentX += size.width + spacing
maxX = max(maxX, currentX)
}
return (CGSize(width: maxX, height: currentY + lineHeight), positions)
}
}
#Preview("Add Email") {
ContactFieldEditorSheet(fieldType: .email) { value, title in
print("Saved: \(value), \(title)")
}
}
#Preview("Edit LinkedIn") {
ContactFieldEditorSheet(
fieldType: .linkedIn,
initialValue: "linkedin.com/in/mattbruce",
initialTitle: "Connect with me"
) { value, title in
print("Saved: \(value), \(title)")
} onDelete: {
print("Deleted")
}
}
#Preview("Add Address") {
ContactFieldEditorSheet(fieldType: .address) { value, title in
print("Saved address: \(value), title: \(title)")
}
}
#Preview("Edit Address") {
let existingAddress = PostalAddress(
street: "6565 Headquarters Dr",
city: "Plano",
state: "TX",
postalCode: "75024"
)
ContactFieldEditorSheet(
fieldType: .address,
initialValue: existingAddress.encode(),
initialTitle: "Work"
) { value, title in
print("Saved: \(value), title: \(title)")
} onDelete: {
print("Deleted")
}
}