
- 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
717 lines
24 KiB
Swift
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)
|
|
}
|
|
} |