- Full-screen views: Added WarmGradientBackground() to CompleteTaskView, ContractorDetailView, DocumentDetailView, DocumentFormView, FeatureComparisonView, TaskTemplatesBrowserView, ManageUsersView, ContractorPickerView - Onboarding: Redesigned all 8 screens with organic styling including animated hero sections, gradient buttons, decorative blobs - Components: Updated ErrorView, EmptyStateView, EmptyResidencesView, EmptyTasksView, TaskSuggestionsView, StatView, SummaryStatView, CompletionCardView, DynamicTaskColumnView with organic styling - Applied consistent patterns: OrganicSpacing, naturalShadow modifier, RoundedRectangle with .continuous style, rounded font designs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
386 lines
19 KiB
Swift
386 lines
19 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
ZStack {
|
|
WarmGradientBackground()
|
|
|
|
ScrollView(showsIndicators: false) {
|
|
VStack(spacing: OrganicSpacing.spacious) {
|
|
Spacer()
|
|
.frame(height: OrganicSpacing.comfortable)
|
|
|
|
// Hero Section
|
|
VStack(spacing: OrganicSpacing.comfortable) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [
|
|
Color.appPrimary.opacity(0.15),
|
|
Color.appPrimary.opacity(0.05),
|
|
Color.clear
|
|
],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: 60
|
|
)
|
|
)
|
|
.frame(width: 120, height: 120)
|
|
|
|
Image(systemName: "lock.rotation")
|
|
.font(.system(size: 48, weight: .medium))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
|
|
VStack(spacing: 8) {
|
|
Text("Set New Password")
|
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Text("Create a strong password to secure your account")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
|
|
// Form Card
|
|
VStack(spacing: 20) {
|
|
// Password Requirements
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("PASSWORD REQUIREMENTS")
|
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.tracking(1.2)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
RequirementRow(
|
|
isMet: viewModel.newPassword.count >= 8,
|
|
text: "At least 8 characters"
|
|
)
|
|
RequirementRow(
|
|
isMet: hasLetter,
|
|
text: "Contains letters"
|
|
)
|
|
RequirementRow(
|
|
isMet: hasNumber,
|
|
text: "Contains numbers"
|
|
)
|
|
RequirementRow(
|
|
isMet: passwordsMatch,
|
|
text: "Passwords match"
|
|
)
|
|
}
|
|
.padding(16)
|
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
|
|
// New Password Field
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("NEW PASSWORD")
|
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.tracking(1.2)
|
|
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appPrimary.opacity(0.1))
|
|
.frame(width: 32, height: 32)
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
|
|
Group {
|
|
if isNewPasswordVisible {
|
|
TextField("Enter new password", text: $viewModel.newPassword)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
} else {
|
|
SecureField("Enter new password", text: $viewModel.newPassword)
|
|
}
|
|
}
|
|
.font(.system(size: 16, weight: .medium))
|
|
.focused($focusedField, equals: .newPassword)
|
|
.submitLabel(.next)
|
|
.onSubmit { focusedField = .confirmPassword }
|
|
.onChange(of: viewModel.newPassword) { _, _ in
|
|
viewModel.clearError()
|
|
}
|
|
|
|
Button(action: { isNewPasswordVisible.toggle() }) {
|
|
Image(systemName: isNewPasswordVisible ? "eye.slash.fill" : "eye.fill")
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.stroke(focusedField == .newPassword ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
|
)
|
|
}
|
|
|
|
// Confirm Password Field
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("CONFIRM PASSWORD")
|
|
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.tracking(1.2)
|
|
|
|
HStack(spacing: 12) {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.appPrimary.opacity(0.1))
|
|
.frame(width: 32, height: 32)
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
|
|
Group {
|
|
if isConfirmPasswordVisible {
|
|
TextField("Re-enter new password", text: $viewModel.confirmPassword)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
} else {
|
|
SecureField("Re-enter new password", text: $viewModel.confirmPassword)
|
|
}
|
|
}
|
|
.font(.system(size: 16, weight: .medium))
|
|
.focused($focusedField, equals: .confirmPassword)
|
|
.submitLabel(.go)
|
|
.onSubmit { viewModel.resetPassword() }
|
|
.onChange(of: viewModel.confirmPassword) { _, _ in
|
|
viewModel.clearError()
|
|
}
|
|
|
|
Button(action: { isConfirmPasswordVisible.toggle() }) {
|
|
Image(systemName: isConfirmPasswordVisible ? "eye.slash.fill" : "eye.fill")
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.stroke(focusedField == .confirmPassword ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
|
)
|
|
}
|
|
|
|
// Error Message
|
|
if let errorMessage = viewModel.errorMessage {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.foregroundColor(Color.appError)
|
|
Text(errorMessage)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(Color.appError)
|
|
Spacer()
|
|
}
|
|
.padding(16)
|
|
.background(Color.appError.opacity(0.1))
|
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
|
|
// Success Message
|
|
if let successMessage = viewModel.successMessage {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(Color.appPrimary)
|
|
Text(successMessage)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(Color.appPrimary)
|
|
Spacer()
|
|
}
|
|
.padding(16)
|
|
.background(Color.appPrimary.opacity(0.1))
|
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
|
|
// Reset Password Button
|
|
Button(action: {
|
|
viewModel.resetPassword()
|
|
}) {
|
|
HStack(spacing: 8) {
|
|
if viewModel.isLoading || viewModel.currentStep == .loggingIn {
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
} else {
|
|
Image(systemName: "lock.shield.fill")
|
|
}
|
|
Text(viewModel.currentStep == .loggingIn ? "Logging in..." : (viewModel.isLoading ? "Resetting..." : "Reset Password"))
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 56)
|
|
.foregroundColor(Color.appTextOnPrimary)
|
|
.background(
|
|
isFormValid && !viewModel.isLoading && viewModel.currentStep != .loggingIn
|
|
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
: AnyShapeStyle(Color.appTextSecondary)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.shadow(
|
|
color: isFormValid && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
|
|
radius: 10,
|
|
y: 5
|
|
)
|
|
}
|
|
.disabled(!isFormValid || viewModel.isLoading || viewModel.currentStep == .loggingIn)
|
|
|
|
// Return to Login Button
|
|
if viewModel.currentStep == .success {
|
|
Button(action: {
|
|
viewModel.reset()
|
|
onSuccess()
|
|
}) {
|
|
Text("Return to Login")
|
|
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
.padding(OrganicSpacing.cozy)
|
|
.background(OrganicResetCardBackground())
|
|
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
|
.naturalShadow(.pronounced)
|
|
.padding(.horizontal, 16)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.navigationBarBackButtonHidden(true)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
if (viewModel.resetToken == nil || viewModel.currentStep != .resetPassword) && viewModel.currentStep != .loggingIn {
|
|
Button(action: {
|
|
if viewModel.currentStep == .success {
|
|
viewModel.reset()
|
|
onSuccess()
|
|
} else {
|
|
viewModel.moveToPreviousStep()
|
|
}
|
|
}) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: viewModel.currentStep == .success ? "xmark" : "chevron.left")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
Text(viewModel.currentStep == .success ? "Close" : "Back")
|
|
.font(.system(size: 15, weight: .medium))
|
|
}
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
focusedField = .newPassword
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Requirement Row
|
|
|
|
private struct RequirementRow: View {
|
|
let isMet: Bool
|
|
let text: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: isMet ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary)
|
|
|
|
Text(text)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Background
|
|
|
|
private struct OrganicResetCardBackground: View {
|
|
@Environment(\.colorScheme) var colorScheme
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.appBackgroundSecondary
|
|
|
|
GeometryReader { geo in
|
|
OrganicBlobShape(variation: 2)
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [
|
|
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
|
|
Color.appPrimary.opacity(0.01)
|
|
],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: geo.size.width * 0.5
|
|
)
|
|
)
|
|
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.4)
|
|
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.55)
|
|
.blur(radius: 20)
|
|
}
|
|
|
|
GrainTexture(opacity: 0.015)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
let vm = PasswordResetViewModel()
|
|
vm.currentStep = .resetPassword
|
|
vm.resetToken = "sample-token"
|
|
return ResetPasswordView(viewModel: vm, onSuccess: {})
|
|
}
|