- Created ErrorMessageParser utility for both iOS (Swift) and Android (Kotlin) - Parser detects JSON-formatted error messages and extracts user-friendly text - Identifies when data objects (not errors) are returned and provides generic messages - Updated all API error handling to pass raw error bodies instead of concatenating - Applied ErrorMessageParser across all ViewModels and screens on both platforms - Fixed ContractorApi and DocumentApi to not concatenate error bodies with messages - Updated ApiResultHandler to automatically parse all error messages - Error messages now show "Request failed. Please check your input and try again." instead of raw JSON 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
271 lines
11 KiB
Swift
271 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
struct ResetPasswordView: View {
|
|
@ObservedObject var viewModel: PasswordResetViewModel
|
|
@FocusState private var focusedField: Field?
|
|
@State private var isNewPasswordVisible = false
|
|
@State private var isConfirmPasswordVisible = false
|
|
@Environment(\.dismiss) var dismiss
|
|
var onSuccess: () -> Void
|
|
|
|
enum Field {
|
|
case newPassword, confirmPassword
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
Form {
|
|
// Header Section
|
|
Section {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "lock.rotation")
|
|
.font(.system(size: 60))
|
|
.foregroundStyle(.blue.gradient)
|
|
.padding(.vertical)
|
|
|
|
Text("Set New Password")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
|
|
Text("Create a strong password to secure your account")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical)
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
|
|
// Password Requirements
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: viewModel.newPassword.count >= 8 ? "checkmark.circle.fill" : "circle")
|
|
.foregroundColor(viewModel.newPassword.count >= 8 ? .green : .secondary)
|
|
Text("At least 8 characters")
|
|
.font(.caption)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
Image(systemName: hasLetter ? "checkmark.circle.fill" : "circle")
|
|
.foregroundColor(hasLetter ? .green : .secondary)
|
|
Text("Contains letters")
|
|
.font(.caption)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
Image(systemName: hasNumber ? "checkmark.circle.fill" : "circle")
|
|
.foregroundColor(hasNumber ? .green : .secondary)
|
|
Text("Contains numbers")
|
|
.font(.caption)
|
|
}
|
|
|
|
HStack(spacing: 8) {
|
|
Image(systemName: passwordsMatch ? "checkmark.circle.fill" : "circle")
|
|
.foregroundColor(passwordsMatch ? .green : .secondary)
|
|
Text("Passwords match")
|
|
.font(.caption)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Password Requirements")
|
|
}
|
|
|
|
// New Password Input
|
|
Section {
|
|
HStack {
|
|
if isNewPasswordVisible {
|
|
TextField("Enter new password", text: $viewModel.newPassword)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.focused($focusedField, equals: .newPassword)
|
|
.submitLabel(.next)
|
|
.onSubmit {
|
|
focusedField = .confirmPassword
|
|
}
|
|
} else {
|
|
SecureField("Enter new password", text: $viewModel.newPassword)
|
|
.focused($focusedField, equals: .newPassword)
|
|
.submitLabel(.next)
|
|
.onSubmit {
|
|
focusedField = .confirmPassword
|
|
}
|
|
}
|
|
|
|
Button(action: {
|
|
isNewPasswordVisible.toggle()
|
|
}) {
|
|
Image(systemName: isNewPasswordVisible ? "eye.slash.fill" : "eye.fill")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.onChange(of: viewModel.newPassword) { _, _ in
|
|
viewModel.clearError()
|
|
}
|
|
} header: {
|
|
Text("New Password")
|
|
}
|
|
|
|
// Confirm Password Input
|
|
Section {
|
|
HStack {
|
|
if isConfirmPasswordVisible {
|
|
TextField("Re-enter new password", text: $viewModel.confirmPassword)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.focused($focusedField, equals: .confirmPassword)
|
|
.submitLabel(.go)
|
|
.onSubmit {
|
|
viewModel.resetPassword()
|
|
}
|
|
} else {
|
|
SecureField("Re-enter new password", text: $viewModel.confirmPassword)
|
|
.focused($focusedField, equals: .confirmPassword)
|
|
.submitLabel(.go)
|
|
.onSubmit {
|
|
viewModel.resetPassword()
|
|
}
|
|
}
|
|
|
|
Button(action: {
|
|
isConfirmPasswordVisible.toggle()
|
|
}) {
|
|
Image(systemName: isConfirmPasswordVisible ? "eye.slash.fill" : "eye.fill")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.onChange(of: viewModel.confirmPassword) { _, _ in
|
|
viewModel.clearError()
|
|
}
|
|
} header: {
|
|
Text("Confirm Password")
|
|
}
|
|
|
|
// Error/Success Messages
|
|
if let errorMessage = viewModel.errorMessage {
|
|
Section {
|
|
Label {
|
|
Text(errorMessage)
|
|
.foregroundColor(.red)
|
|
} icon: {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let successMessage = viewModel.successMessage {
|
|
Section {
|
|
Label {
|
|
Text(successMessage)
|
|
.foregroundColor(.green)
|
|
.multilineTextAlignment(.center)
|
|
} icon: {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reset Password Button
|
|
Section {
|
|
Button(action: {
|
|
viewModel.resetPassword()
|
|
}) {
|
|
HStack {
|
|
Spacer()
|
|
if viewModel.isLoading {
|
|
ProgressView()
|
|
} else {
|
|
Label("Reset Password", systemImage: "lock.shield.fill")
|
|
.fontWeight(.semibold)
|
|
}
|
|
Spacer()
|
|
}
|
|
}
|
|
.disabled(!isFormValid || viewModel.isLoading)
|
|
|
|
// Return to Login Button (shown after success)
|
|
if viewModel.currentStep == .success {
|
|
Button(action: {
|
|
viewModel.reset()
|
|
onSuccess()
|
|
}) {
|
|
HStack {
|
|
Spacer()
|
|
Text("Return to Login")
|
|
.fontWeight(.semibold)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Reset Password")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.navigationBarBackButtonHidden(true)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
// Only show back button if not from deep link
|
|
if viewModel.resetToken == nil || viewModel.currentStep != .resetPassword {
|
|
Button(action: {
|
|
if viewModel.currentStep == .success {
|
|
viewModel.reset()
|
|
onSuccess()
|
|
} else {
|
|
viewModel.moveToPreviousStep()
|
|
}
|
|
}) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: viewModel.currentStep == .success ? "xmark" : "chevron.left")
|
|
.font(.system(size: 16))
|
|
Text(viewModel.currentStep == .success ? "Close" : "Back")
|
|
.font(.subheadline)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
focusedField = .newPassword
|
|
}
|
|
.handleErrors(
|
|
error: viewModel.errorMessage,
|
|
onRetry: { viewModel.resetPassword() }
|
|
)
|
|
}
|
|
}
|
|
|
|
// Computed Properties
|
|
private var hasLetter: Bool {
|
|
viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil
|
|
}
|
|
|
|
private var hasNumber: Bool {
|
|
viewModel.newPassword.range(of: "[0-9]", options: .regularExpression) != nil
|
|
}
|
|
|
|
private var passwordsMatch: Bool {
|
|
!viewModel.newPassword.isEmpty &&
|
|
!viewModel.confirmPassword.isEmpty &&
|
|
viewModel.newPassword == viewModel.confirmPassword
|
|
}
|
|
|
|
private var isFormValid: Bool {
|
|
viewModel.newPassword.count >= 8 &&
|
|
hasLetter &&
|
|
hasNumber &&
|
|
passwordsMatch
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
let vm = PasswordResetViewModel()
|
|
vm.currentStep = .resetPassword
|
|
vm.resetToken = "sample-token"
|
|
return ResetPasswordView(viewModel: vm, onSuccess: {})
|
|
}
|