319 lines
11 KiB
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")
|
|
}
|
|
}
|