Untitled

 avatar
unknown
plain_text
6 days ago
9.8 kB
15
Indexable
import SwiftUI
import Foundation
import LifenessCore
import LifenessUI
import LifenessCommon

struct EditPersonalDataView: View {
  @Environment(\.window) var window
  @EnvironmentObject var store: Store<AppState>
  @StateObject var viewModel: EditPersonalDataViewModel
  
  // UI State variables
  @State private var showValidationErrors: Bool = false
  @State private var showAlert: Bool = false
  @State private var isDobValid: Bool = true
  @State private var dobError: String? = nil
  
  let state: NavigationItem.EditPersonalData
  private let initialData: PersonalData
  @State private var isUsernameInitiallyEmpty: Bool
  
  init(state: NavigationItem.EditPersonalData) {
    self.state = state
    self._viewModel = .init(wrappedValue: .init(personalData: state.data))
    self.initialData = state.data
    self._isUsernameInitiallyEmpty = State(initialValue: state.data.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
  }
  
  var body: some View {
    ZStack {
      Color.custom(.grey200).edgesIgnoringSafeArea(.all)
      
      let isDiga = store.context.env.appVariant == .diga
      let isDefaultVariant = store.context.env.appVariant == .default
      
      VStack(spacing: 0) {
        ScrollView {
          VStack(spacing: 16) {
            SectionHeaderView(localized("profile_text"))
            
            validatedInput($viewModel.personalInfo.firstName, forField: "firstName")
            validatedInput($viewModel.personalInfo.lastName, forField: "lastName")
            
            validatedInput($viewModel.personalInfo.username, forField: "username")
              .disabled(!isUsernameInitiallyEmpty)
              .submitLabel(.done)
              .onSubmit { window?.endEditing(true) }
            
            let isWellapyfr = store.context.env.appVariant == .wellapyfr
            
            PhoneInputView(viewModel: $viewModel.personalInfo.phone)
              .keyboardType(.numberPad)
              .disabled(!isWellapyfr)
            
            if !isDefaultVariant {
              validatedInput($viewModel.personalInfo.email, forField: "email")
                .keyboardType(.emailAddress)
                .disabled(isWellapyfr == false)
            }
            
            if isDiga {
              InputView(viewModel: $viewModel.personalInfo.bornYear)
            } else {
              let dobBinding = Binding<Date?>(
                get: {
                  let value = viewModel.personalInfo.dob
                  return Calendar.current.isDate(value ?? Date(), inSameDayAs: Date()) ? nil : value
                },
                set: { viewModel.personalInfo.dob = $0 }
              )
              
              VStack(alignment: .leading, spacing: 2) {
                DatePickerNullable(
                  placeholder: DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .none),
                  selection: dobBinding,
                  dateUpperBound: Date(),
                  isClearable: false,
                  onDone: {
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
                      validateDate(showAlertOnFailure: true)
                    }
                  }
                )
                
                if let error = dobError {
                  Text(error)
                    .textStyle(.body2(.medium), color: .custom(.red))
                    .padding(2)
                    .padding(.horizontal, 22)
                    .frame(maxWidth: .infinity, alignment: .leading)
                }
              }
            }
            
            GenderPickerCard($viewModel.personalStats.gender)
              .padding(.bottom, 8)
              .onChange(of: viewModel.personalStats.gender) { _ in
                viewModel.updateHasChanges()
              }
            
            SectionHeaderView(localized("measurements_text"))
            
            Group {
              validatedInput($viewModel.personalStats.startingWeight, forField: "startingWeight")
              validatedInput($viewModel.personalStats.maxWeight, forField: "maxWeight")
              validatedInput($viewModel.personalStats.minWeight, forField: "minWeight")
              validatedInput($viewModel.personalStats.currentWeight, forField: "currentWeight")
              InputView(viewModel: $viewModel.personalStats.currentWaistCircumference)
            }
            .keyboardType(.decimalPad)
            
            validatedInput($viewModel.personalStats.height, forField: "height")
              .keyboardType(.numberPad)
            
            VStack(alignment: .leading) {
              CheckBoxButton(
                isChecked: $viewModel.dataProcessingAgreement,
                label: localized(\.common_above_data_correct)
              )
              .textStyle(.body1(.regular))
              .padding(.top, 18)
              .fixedSize(horizontal: false, vertical: true)
            }
          }
          .padding(.horizontal, 24)
          .padding(.vertical, 26)
        }
        
        BottomButton(title: localized("save_button")) {
          saveTapped()
        }
        .disabled(!(viewModel.hasChanges && viewModel.dataProcessingAgreement))
      }
    }
    .navigationBarTitle(
      Text(localized("profile_edit_data")),
      displayMode: .inline
    )
    .onTapEndEditing()
    .navigationBarHidden(false)
    .navigationBarExitButton {
      store.send(UserAction.ExitPersonalDataEdit(
        initial: state.data,
        current: viewModel.createPersonalData()
      ))
    }
  }
  
  private func validatedInput(_ field: Binding<InputViewModel>, forField fieldName: String) -> some View {
    InputView(viewModel: Binding(
      get: {
        var updated = field.wrappedValue
        if showValidationErrors {
          updated.error = validateField(updated.text, forField: fieldName)
        }
        return updated
      },
      set: { field.wrappedValue = $0 }
    ))
  }
  
  private func validateField(_ value: String, forField field: String) -> String? {
    let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
    
    if trimmed.isEmpty && ["firstName", "lastName", "currentWeight", "height"].contains(field) {
      return localized("input_required_error")
    }
    
    if field == "username" {
      if trimmed.count < 5 {
        return localized("input_min_five_characters_required")
      }
      let regex = #"^[a-zA-Z][a-zA-Z0-9]{4,24}$"#
      if !trimmed.matches(regex) {
        return localized("input_username_invalid_format")
      }
    }
    
    if field == "email" {
      if trimmed.isEmpty {
        return localized("input_required_error")
      }
      let regex = #"[^@]+@[^.]+\..+"#
      if !trimmed.matches(regex) {
        return localized(\.common_input_error_invalid_email)
      }
    }
    
    if let number = Double(trimmed), ["currentWeight", "startingWeight", "minWeight", "maxWeight"].contains(field) {
      if number <= 0 || number >= 500 {
        return localized("input_value_must_be_less_than_500")
      }
    }
    
    return nil
  }
  
  private func validateDate(showAlertOnFailure: Bool = false) {
    guard let date = viewModel.personalInfo.dob else {
      dobError = localized("input_required_error")
      isDobValid = false
      return
    }
    
    let fiveYearsAgo = Calendar.current.date(byAdding: .year, value: -5, to: Date())!
    
    if date > fiveYearsAgo {
      dobError = localized("date_too_recent_error")
      isDobValid = false
      
      if showAlertOnFailure {
        showAlert = true
      }
    } else {
      isDobValid = true
      dobError = nil
    }
  }
  
  private func saveTapped() {
    window?.endEditing(true)
    showValidationErrors = true
    
    let isWellapyfr = store.context.env.appVariant == .wellapyfr
    
    validateDate(showAlertOnFailure: false)
    var hasErrors = false
    
    if isWellapyfr {
      let phoneError = viewModel.personalInfo.phone.phoneNumber.validatePhoneNumber()
      viewModel.personalInfo.phone.error = phoneError
      if phoneError != nil {
        hasErrors = true
      }
    }
    
    if hasErrors { return }
    guard isDobValid else { return }
    
    var fieldsToValidate = [
      ($viewModel.personalInfo.firstName, "firstName"),
      ($viewModel.personalInfo.lastName, "lastName"),
      ($viewModel.personalInfo.username, "username"),
      ($viewModel.personalStats.currentWeight, "currentWeight"),
      ($viewModel.personalStats.height, "height")
    ]
    
    if isWellapyfr {
      fieldsToValidate.append(($viewModel.personalInfo.email, "email"))
    }
    
    for (field, fieldName) in fieldsToValidate {
      let error = validateField(field.wrappedValue.text, forField: fieldName)
      field.wrappedValue.error = error
    }
    
    guard fieldsToValidate.allSatisfy({ validateField($0.wrappedValue.text, forField: $1) == nil }) else { return }
    guard let data = viewModel.createPersonalDataIfValid() else { return }
    store.send(UserAction.UpdatePersonalData(data))
  }
}

#if DEBUG
#Preview {
  navigationPreview(
    EditPersonalDataView.init,
    NavigationItem.EditPersonalData(data: .testData(), id: "1")
  )
  .environmentObject(storePreview(createAppState()))
}
#endif

fileprivate extension String {
  func matches(_ pattern: String) -> Bool {
    return self.range(of: pattern, options: .regularExpression) != nil
  }
}

extension String {
  func validatePhoneNumber() -> String? {
    let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines)
    if trimmed.isEmpty {
      return localized("input_required_error")
    }
    let digits = trimmed.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
    if !(8...20).contains(digits.count) {
      return localized("phone_number_invalid")
    }
    return nil
  }
}
Editor is loading...
Leave a Comment