RTSDA-iOS/Views/ContactFormView.swift
RTSDA 00679f927c docs: Update README for v2.0 release and fix git remote URL
- Comprehensive README update documenting v2.0 architectural changes
- Updated git remote to ssh://rockvilleav@git.rockvilletollandsda.church:10443/RTSDA/RTSDA-iOS.git
- Documented unified ChurchService and 60% code reduction
- Added new features: Home Feed, responsive reading, enhanced UI
- Corrected license information (GPL v3 with church content copyright)
- Updated build instructions and technical stack details
2025-08-16 18:41:51 -04:00

717 lines
24 KiB
Swift

import SwiftUI
// MARK: - Rust Integration Data Models
struct ContactFormData: Codable {
let name: String
let email: String
let phone: String
let message: String
let subject: String
}
struct ValidationResult: Codable {
let isValid: Bool
let errors: [String]
enum CodingKeys: String, CodingKey {
case isValid = "is_valid"
case errors
}
}
struct ChurchConfig: Codable {
let contactPhone: String
let contactEmail: String
let churchAddress: String
let churchName: String
enum CodingKeys: String, CodingKey {
case contactPhone = "contact_phone"
case contactEmail = "contact_email"
case churchAddress = "church_address"
case churchName = "church_name"
}
}
struct ContactFormView: View {
let isModal: Bool
@State private var name = ""
@State private var email = ""
@State private var phone = ""
@State private var subject = "General Inquiry"
@State private var message = ""
@State private var isSubmitting = false
@State private var showingSuccess = false
@State private var showingError = false
@State private var showingAlert = false
@State private var errorMessage = ""
@State private var hasAttemptedSubmit = false
@State private var churchConfig: ChurchConfig?
@FocusState private var focusedField: Bool
private let subjectOptions = [
"General Inquiry",
"Prayer Request",
"Bible Study Interest",
"Membership Information",
"Pastoral Care",
"Adventist Youth",
"Other"
]
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(ChurchDataService.self) private var dataService
init(isModal: Bool = false) {
self.isModal = isModal
}
private var alertMessage: String {
showingSuccess ? "Thank you for reaching out! We'll get back to you as soon as possible." : errorMessage
}
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("We'd love to hear from you! Send us a message and we'll get back to you as soon as possible.")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineSpacing(4)
}
}
private var loadingOverlay: some View {
Color.black.opacity(0.3)
.ignoresSafeArea()
.overlay {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.scaleEffect(1.5)
.tint(.orange)
}
}
var body: some View {
NavigationStack {
Form {
Section {
headerSection
}
Section("Your Information") {
VStack(alignment: .leading, spacing: 4) {
CustomTextField(title: "Name", text: $name, placeholder: "Enter your full name")
.focused($focusedField)
if let nameError = getFieldError(for: "name") {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
.font(.caption)
Text(nameError)
.font(.caption)
.foregroundColor(.red)
}
}
}
VStack(alignment: .leading, spacing: 4) {
CustomTextField(title: "Email", text: $email, placeholder: "Enter your email address")
.keyboardType(.emailAddress)
.autocapitalization(.none)
.focused($focusedField)
if let emailError = getFieldError(for: "email") {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
.font(.caption)
Text(emailError)
.font(.caption)
.foregroundColor(.red)
}
}
}
VStack(alignment: .leading, spacing: 4) {
PhoneTextField(
title: "Phone (Optional)",
text: $phone,
placeholder: "(555) 123-4567",
icon: "phone.fill"
)
.focused($focusedField)
if let phoneError = getFieldError(for: "phone") {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
.font(.caption)
Text(phoneError)
.font(.caption)
.foregroundColor(.red)
}
}
}
}
Section("Subject") {
Menu {
ForEach(subjectOptions, id: \.self) { option in
Button(option) {
subject = option
}
}
} label: {
HStack {
Text(subject)
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.down")
.foregroundColor(.secondary)
.font(.caption)
}
}
}
Section("Message") {
VStack(alignment: .leading, spacing: 4) {
TextEditor(text: $message)
.frame(minHeight: 120)
.focused($focusedField)
if let messageError = getFieldError(for: "message") {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
.font(.caption)
Text(messageError)
.font(.caption)
.foregroundColor(.red)
}
}
}
}
Section {
customSubmitButton
if hasAttemptedSubmit && !validationResult.isValid {
VStack(alignment: .leading, spacing: 8) {
ForEach(validationResult.errors, id: \.self) { error in
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(error)
.font(.caption)
.foregroundColor(.red)
Spacer()
}
}
}
.padding()
.background(.red.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
}
}
}
.navigationTitle(isModal ? "Contact Us" : "Get in Touch")
.navigationBarTitleDisplayMode(isModal ? .inline : .large)
.toolbar {
if isModal {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
.disabled(isSubmitting)
.overlay {
if isSubmitting {
loadingOverlay
}
}
.alert("Success", isPresented: $showingSuccess) {
Button("OK") {
if isModal {
dismiss()
} else {
clearForm()
}
}
} message: {
Text("Thank you for reaching out! We'll get back to you as soon as possible.")
}
.alert("Error", isPresented: $showingError) {
Button("OK") { }
} message: {
Text(errorMessage)
}
.onAppear {
loadChurchConfig()
}
.onDisappear {
focusedField = false
}
}
private var customSubmitButton: some View {
Button {
submitForm()
} label: {
HStack {
if isSubmitting {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.scaleEffect(0.8)
.tint(.white)
Text("Sending...")
} else {
Image(systemName: "paperplane.fill")
Text("Send Message")
}
}
.font(.headline)
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.foregroundStyle(.white)
.background(
(isFormValid && !isSubmitting) ? Color(hex: getBrandColor()) : Color(.systemGray3),
in: RoundedRectangle(cornerRadius: 12)
)
.shadow(color: isFormValid && !isSubmitting ? Color(hex: getBrandColor()).opacity(0.3) : Color.clear, radius: 4, y: 2)
}
.disabled(!isFormValid || isSubmitting)
}
private var quickContactOptions: some View {
VStack(spacing: 16) {
Divider()
.padding(.vertical, 12)
Text("Other Ways to Reach Us")
.font(.subheadline)
.fontWeight(.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
VStack(spacing: 12) {
// Only show Call Us on iPhone - iPads can't make phone calls
if UIDevice.current.userInterfaceIdiom == .phone {
QuickContactRow(
icon: "phone.fill",
title: "Call Us",
subtitle: churchConfig?.contactPhone ?? "(860) 875-0450",
color: .green
) {
let phoneNumber = churchConfig?.contactPhone ?? "(860) 875-0450"
let digitsOnly = phoneNumber.filter { $0.isNumber }
let phoneURL = URL(string: "tel://\(digitsOnly)")
// iPhone - make the call
if let url = phoneURL, UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
}
}
}
QuickContactRow(
icon: "envelope.fill",
title: "Email Us",
subtitle: getContactEmail(),
color: .blue
) {
if let url = URL(string: "mailto:\(getContactEmail())") {
UIApplication.shared.open(url)
}
}
QuickContactRow(
icon: "location.fill",
title: "Visit Us",
subtitle: "9 Hartford Turnpike, Tolland, CT",
color: .red
) {
if let url = URL(string: "https://maps.apple.com/?address=9+Hartford+Turnpike,+Tolland,+CT+06084") {
UIApplication.shared.open(url)
}
}
}
.padding(.horizontal, 16)
}
.listRowInsets(EdgeInsets())
}
private var isFormValid: Bool {
return validationResult.isValid
}
private var validationResult: ValidationResult {
let formData = ContactFormData(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
phone: phone.trimmingCharacters(in: .whitespacesAndNewlines),
message: message.trimmingCharacters(in: .whitespacesAndNewlines),
subject: subject.trimmingCharacters(in: .whitespacesAndNewlines)
)
guard let jsonData = try? JSONEncoder().encode(formData),
let jsonString = String(data: jsonData, encoding: .utf8) else {
return ValidationResult(isValid: false, errors: ["System error"])
}
let resultJson = validateContactFormJson(formJson: jsonString)
guard let data = resultJson.data(using: .utf8),
let result = try? JSONDecoder().decode(ValidationResult.self, from: data) else {
return ValidationResult(isValid: false, errors: ["System error"])
}
return result
}
private var validationErrors: [String] {
return validationResult.errors
}
private func getFieldError(for field: String) -> String? {
// Only show errors after user has attempted to submit or field has content
guard hasAttemptedSubmit || hasFieldContent(field) else { return nil }
let errors = validationResult.errors
return errors.first { error in
switch field {
case "name":
return error.lowercased().contains("name")
case "email":
return error.lowercased().contains("email")
case "phone":
return error.lowercased().contains("phone")
case "message":
return error.lowercased().contains("message")
default:
return false
}
}
}
private func hasFieldContent(_ field: String) -> Bool {
switch field {
case "name":
return !name.isEmpty
case "email":
return !email.isEmpty
case "phone":
return !phone.isEmpty
case "message":
return !message.isEmpty
default:
return false
}
}
private func submitForm() {
hasAttemptedSubmit = true
guard isFormValid else { return }
isSubmitting = true
Task {
let success = await dataService.submitContact(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
subject: subject.trimmingCharacters(in: .whitespacesAndNewlines),
message: message.trimmingCharacters(in: .whitespacesAndNewlines),
phone: phone.trimmingCharacters(in: .whitespacesAndNewlines)
)
await MainActor.run {
isSubmitting = false
if success {
showingSuccess = true
} else {
errorMessage = "Failed to send message. Please try again or contact us directly."
showingError = true
}
}
}
}
private func clearForm() {
name = ""
email = ""
phone = ""
subject = "General Inquiry"
message = ""
hasAttemptedSubmit = false
}
private func loadChurchConfig() {
Task {
let configJson = fetchConfigJson()
// Parse the API response structure
guard let data = configJson.data(using: .utf8),
let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let configData = response["data"] as? [String: Any],
let configDataJson = try? JSONSerialization.data(withJSONObject: configData),
let config = try? JSONDecoder().decode(ChurchConfig.self, from: configDataJson) else {
return
}
await MainActor.run {
self.churchConfig = config
}
}
}
}
// MARK: - Phone Text Field with Formatting
// MARK: - Custom Text Field
struct CustomTextField: View {
let title: String
@Binding var text: String
let placeholder: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.secondary)
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.padding()
.background(.background, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(.quaternary, lineWidth: 1)
)
}
}
}
struct PhoneTextField: View {
let title: String
@Binding var text: String
let placeholder: String
let icon: String
var errorMessage: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: icon)
.foregroundStyle(Color(hex: getBrandColor()))
.frame(width: 20)
Text(title)
.font(.subheadline)
.fontWeight(.medium)
}
.padding(.top, 16)
.padding(.leading, 16)
TextField(placeholder, text: $text)
.keyboardType(.phonePad)
.textContentType(.telephoneNumber)
.padding(16)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(errorMessage != nil ? Color.red : Color(.systemGray4), lineWidth: errorMessage != nil ? 1.0 : 0.5)
)
.onChange(of: text) { _, newValue in
text = formatPhoneNumber(newValue)
}
if let errorMessage = errorMessage {
HStack {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(.red)
.font(.caption)
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
}
.padding(.leading, 20)
}
}
.listRowInsets(EdgeInsets())
}
private func formatPhoneNumber(_ input: String) -> String {
// Remove all non-numeric characters
let digitsOnly = input.filter { $0.isNumber }
// Don't format if too long
if digitsOnly.count > 10 {
return String(digitsOnly.prefix(10))
}
// Format based on length
switch digitsOnly.count {
case 0...3:
return digitsOnly
case 4...6:
let area = String(digitsOnly.prefix(3))
let next = String(digitsOnly.dropFirst(3))
return "(\(area)) \(next)"
case 7...10:
let area = String(digitsOnly.prefix(3))
let middle = String(digitsOnly.dropFirst(3).prefix(3))
let last = String(digitsOnly.dropFirst(6))
return "(\(area)) \(middle)-\(last)"
default:
return digitsOnly
}
}
}
// MARK: - Custom Subject Picker
struct CustomSubjectPicker: View {
let title: String
@Binding var selection: String
let options: [String]
let icon: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: icon)
.foregroundStyle(Color(hex: getBrandColor()))
.frame(width: 20)
Text(title)
.font(.subheadline)
.fontWeight(.medium)
}
Menu {
ForEach(options, id: \.self) { option in
Button(option) {
selection = option
}
}
} label: {
HStack {
Text(selection)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(12)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(.systemGray4), lineWidth: 0.5)
)
}
}
}
}
// MARK: - Quick Contact Row
struct QuickContactRow: View {
let icon: String
let title: String
let subtitle: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.primary)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - Service Times Section
struct ServiceTimesSection: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Service Times")
.font(.headline)
.fontWeight(.semibold)
VStack(spacing: 12) {
ServiceTimeRow(
day: "Saturday",
time: "9:15 AM",
service: "Sabbath School"
)
ServiceTimeRow(
day: "Saturday",
time: "11:00 AM",
service: "Worship Service"
)
ServiceTimeRow(
day: "Wednesday",
time: "6:30 PM",
service: "Prayer Meeting"
)
}
}
}
}
#Preview("Contact Form") {
NavigationStack {
ContactFormView()
.environment(ChurchDataService.shared)
}
}
#Preview("Modal Contact Form") {
NavigationStack {
ContactFormView(isModal: true)
.environment(ChurchDataService.shared)
}
}