| 1 | import SwiftUI |
| 2 | |
| 3 | private enum AccountTextContentType { |
| 4 | case username |
| 5 | case password |
| 6 | case newPassword |
| 7 | } |
| 8 | |
| 9 | #if os(iOS) || os(tvOS) || os(visionOS) |
| 10 | import UIKit |
| 11 | typealias AccountKeyboardType = UIKeyboardType |
| 12 | #else |
| 13 | enum AccountKeyboardType { |
| 14 | case emailAddress |
| 15 | } |
| 16 | #endif |
| 17 | |
| 18 | private enum AccountFormMode: String, CaseIterable, Identifiable { |
| 19 | case login = "Log in" |
| 20 | case signup = "Sign up" |
| 21 | |
| 22 | var id: String { rawValue } |
| 23 | } |
| 24 | |
| 25 | struct AccountView: View { |
| 26 | @EnvironmentObject private var accountStore: AccountStore |
| 27 | |
| 28 | @State private var formMode: AccountFormMode = .login |
| 29 | @State private var loginEmail = "" |
| 30 | @State private var loginPassword = "" |
| 31 | @State private var signupEmail = "" |
| 32 | @State private var signupPassword = "" |
| 33 | @State private var signupPasswordConfirmation = "" |
| 34 | @State private var showingRemoveConfirmation = false |
| 35 | |
| 36 | var body: some View { |
| 37 | ScrollView { |
| 38 | VStack(alignment: .leading, spacing: 18) { |
| 39 | #if os(tvOS) |
| 40 | TVPageHeader("Account") |
| 41 | #endif |
| 42 | |
| 43 | SectionHeader( |
| 44 | "Account", |
| 45 | subtitle: |
| 46 | "Create an account, sign in, and keep your profile ready in Klepon." |
| 47 | ) |
| 48 | |
| 49 | AccountStatusBanner( |
| 50 | notice: accountStore.notice, |
| 51 | errorMessage: accountStore.errorMessage |
| 52 | ) |
| 53 | |
| 54 | AccountProxyInfoCard() |
| 55 | |
| 56 | if accountStore.isSignedIn { |
| 57 | AccountProfileCard( |
| 58 | user: accountStore.currentUser, |
| 59 | isBusy: accountStore.isBusy, |
| 60 | onRefresh: { |
| 61 | Task { |
| 62 | await accountStore.refreshProfile() |
| 63 | } |
| 64 | }, |
| 65 | onSaveProfile: { name, username in |
| 66 | await accountStore.updateProfile(name: name, username: username) |
| 67 | }, |
| 68 | onUpdatePassword: { currentPassword, newPassword, passwordConfirmation in |
| 69 | await accountStore.updatePassword( |
| 70 | currentPassword: currentPassword, |
| 71 | newPassword: newPassword, |
| 72 | passwordConfirmation: passwordConfirmation |
| 73 | ) |
| 74 | }, |
| 75 | onLogout: { |
| 76 | Task { |
| 77 | await accountStore.logout() |
| 78 | } |
| 79 | }, |
| 80 | onRemoveAccount: { |
| 81 | showingRemoveConfirmation = true |
| 82 | } |
| 83 | ) |
| 84 | } else { |
| 85 | AccountModePicker(formMode: $formMode) |
| 86 | |
| 87 | if formMode == .login { |
| 88 | AccountLoginCard( |
| 89 | email: $loginEmail, |
| 90 | password: $loginPassword, |
| 91 | isBusy: accountStore.isBusy, |
| 92 | canSubmit: !loginEmail.trimmingCharacters(in: .whitespacesAndNewlines) |
| 93 | .isEmpty && !loginPassword.isEmpty, |
| 94 | onSubmit: { |
| 95 | Task { |
| 96 | let didLogin = await accountStore.login( |
| 97 | email: loginEmail, |
| 98 | password: loginPassword |
| 99 | ) |
| 100 | if didLogin { |
| 101 | loginPassword = "" |
| 102 | } |
| 103 | } |
| 104 | }, |
| 105 | onResendConfirmation: { |
| 106 | Task { |
| 107 | await accountStore.resendConfirmation(email: loginEmail) |
| 108 | } |
| 109 | }, |
| 110 | onForgotPassword: { |
| 111 | Task { |
| 112 | await accountStore.requestPasswordReset(email: loginEmail) |
| 113 | } |
| 114 | } |
| 115 | ) |
| 116 | } else { |
| 117 | AccountSignupCard( |
| 118 | email: $signupEmail, |
| 119 | password: $signupPassword, |
| 120 | passwordConfirmation: $signupPasswordConfirmation, |
| 121 | isBusy: accountStore.isBusy, |
| 122 | canSubmit: canSubmitSignup, |
| 123 | onSubmit: { |
| 124 | Task { |
| 125 | let result = await accountStore.signUp( |
| 126 | email: signupEmail, |
| 127 | password: signupPassword |
| 128 | ) |
| 129 | |
| 130 | guard result != nil else { return } |
| 131 | |
| 132 | loginEmail = signupEmail |
| 133 | loginPassword = signupPassword |
| 134 | signupPassword = "" |
| 135 | signupPasswordConfirmation = "" |
| 136 | formMode = .login |
| 137 | } |
| 138 | } |
| 139 | ) |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | .padding(.horizontal, 20) |
| 144 | .padding(.vertical, 16) |
| 145 | .kleponReadableContentWidth() |
| 146 | } |
| 147 | .background(KleponColor.background.ignoresSafeArea()) |
| 148 | .navigationTitle("Account") |
| 149 | #if os(tvOS) |
| 150 | .toolbar(.hidden, for: .navigationBar) |
| 151 | #else |
| 152 | .kleponInlineNavigationTitle() |
| 153 | #endif |
| 154 | .task { |
| 155 | await accountStore.restoreSessionIfNeeded() |
| 156 | } |
| 157 | .confirmationDialog( |
| 158 | "Delete this account?", |
| 159 | isPresented: $showingRemoveConfirmation, |
| 160 | titleVisibility: .visible |
| 161 | ) { |
| 162 | Button("Remove account", role: .destructive) { |
| 163 | Task { |
| 164 | await accountStore.removeAccount() |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | Button("Cancel", role: .cancel) {} |
| 169 | } message: { |
| 170 | Text( |
| 171 | "This permanently removes the account tied to your current session." |
| 172 | ) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | private var canSubmitSignup: Bool { |
| 177 | !signupEmail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| 178 | && !signupPassword.isEmpty |
| 179 | && signupPassword == signupPasswordConfirmation |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | struct AccountSettingsCard: View { |
| 184 | @EnvironmentObject private var accountStore: AccountStore |
| 185 | |
| 186 | var body: some View { |
| 187 | NavigationLink { |
| 188 | AccountView() |
| 189 | } label: { |
| 190 | KleponCard { |
| 191 | VStack(alignment: .leading, spacing: 12) { |
| 192 | HStack(alignment: .top, spacing: 12) { |
| 193 | Image( |
| 194 | systemName: accountStore.isSignedIn |
| 195 | ? "person.crop.circle.badge.checkmark" : "person.crop.circle" |
| 196 | ) |
| 197 | .font(.system(size: 24)) |
| 198 | .foregroundStyle(KleponColor.accent) |
| 199 | |
| 200 | VStack(alignment: .leading, spacing: 6) { |
| 201 | Text("Account") |
| 202 | .font(KleponTypography.cardTitle) |
| 203 | .foregroundStyle(KleponColor.textPrimary) |
| 204 | |
| 205 | Text(accountStore.statusTitle) |
| 206 | .font(KleponTypography.bodySecondary.weight(.semibold)) |
| 207 | .foregroundStyle(KleponColor.textPrimary) |
| 208 | |
| 209 | Text(accountStore.statusDetail) |
| 210 | .font(KleponTypography.caption) |
| 211 | .foregroundStyle(KleponColor.textSecondary) |
| 212 | } |
| 213 | |
| 214 | Spacer(minLength: 12) |
| 215 | |
| 216 | Image(systemName: "chevron.right") |
| 217 | .font(.footnote.weight(.semibold)) |
| 218 | .foregroundStyle(KleponColor.textSecondary) |
| 219 | } |
| 220 | |
| 221 | HStack(spacing: 10) { |
| 222 | KleponChip( |
| 223 | title: "Email account", |
| 224 | icon: "person.crop.circle" |
| 225 | ) |
| 226 | |
| 227 | KleponChip( |
| 228 | title: accountStore.isSignedIn ? "Profile ready" : "Signed out", |
| 229 | icon: accountStore.isSignedIn |
| 230 | ? "checkmark.circle" : "rectangle.portrait.and.arrow.right" |
| 231 | ) |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | } |
| 236 | .kleponInteractiveButtonStyle() |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | private struct AccountStatusBanner: View { |
| 241 | let notice: String? |
| 242 | let errorMessage: String? |
| 243 | |
| 244 | var body: some View { |
| 245 | if let errorMessage { |
| 246 | KleponCard { |
| 247 | VStack(alignment: .leading, spacing: 8) { |
| 248 | Label("Something needs attention", systemImage: "exclamationmark.triangle.fill") |
| 249 | .font(KleponTypography.bodySecondary.weight(.semibold)) |
| 250 | .foregroundStyle(KleponColor.highlight) |
| 251 | |
| 252 | Text(errorMessage) |
| 253 | .font(KleponTypography.bodySecondary) |
| 254 | .foregroundStyle(KleponColor.textSecondary) |
| 255 | } |
| 256 | } |
| 257 | } else if let notice { |
| 258 | KleponCard { |
| 259 | VStack(alignment: .leading, spacing: 8) { |
| 260 | Label("Status", systemImage: "checkmark.circle.fill") |
| 261 | .font(KleponTypography.bodySecondary.weight(.semibold)) |
| 262 | .foregroundStyle(KleponColor.accent) |
| 263 | |
| 264 | Text(notice) |
| 265 | .font(KleponTypography.bodySecondary) |
| 266 | .foregroundStyle(KleponColor.textSecondary) |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | private struct AccountProxyInfoCard: View { |
| 274 | var body: some View { |
| 275 | KleponCard { |
| 276 | VStack(alignment: .leading, spacing: 12) { |
| 277 | Text("Private sign in") |
| 278 | .font(KleponTypography.cardTitle) |
| 279 | .foregroundStyle(KleponColor.textPrimary) |
| 280 | |
| 281 | Text( |
| 282 | "Create your account, confirm your email, and reset your password right from Klepon." |
| 283 | ) |
| 284 | .font(KleponTypography.bodySecondary) |
| 285 | .foregroundStyle(KleponColor.textSecondary) |
| 286 | |
| 287 | HStack(spacing: 10) { |
| 288 | KleponChip(title: "Email sign in", icon: "envelope") |
| 289 | KleponChip(title: "Password reset", icon: "key") |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | private struct AccountModePicker: View { |
| 297 | @Binding var formMode: AccountFormMode |
| 298 | |
| 299 | var body: some View { |
| 300 | HStack(spacing: 10) { |
| 301 | ForEach(AccountFormMode.allCases) { mode in |
| 302 | Button { |
| 303 | formMode = mode |
| 304 | } label: { |
| 305 | Text(mode.rawValue) |
| 306 | .font(KleponTypography.bodySecondary.weight(.semibold)) |
| 307 | .frame(maxWidth: .infinity) |
| 308 | .padding(.vertical, 12) |
| 309 | .background( |
| 310 | RoundedRectangle(cornerRadius: 16, style: .continuous) |
| 311 | .fill( |
| 312 | formMode == mode |
| 313 | ? KleponColor.accent : KleponColor.surfaceSecondary) |
| 314 | ) |
| 315 | .foregroundStyle(formMode == mode ? Color.white : KleponColor.textPrimary) |
| 316 | } |
| 317 | .kleponInteractiveButtonStyle() |
| 318 | } |
| 319 | } |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | private struct AccountLoginCard: View { |
| 324 | @Binding var email: String |
| 325 | @Binding var password: String |
| 326 | |
| 327 | let isBusy: Bool |
| 328 | let canSubmit: Bool |
| 329 | let onSubmit: () -> Void |
| 330 | let onResendConfirmation: () -> Void |
| 331 | let onForgotPassword: () -> Void |
| 332 | |
| 333 | private var canRequestEmailAction: Bool { |
| 334 | !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| 335 | } |
| 336 | |
| 337 | var body: some View { |
| 338 | KleponCard { |
| 339 | VStack(alignment: .leading, spacing: 14) { |
| 340 | Text("Log in") |
| 341 | .font(KleponTypography.cardTitle) |
| 342 | .foregroundStyle(KleponColor.textPrimary) |
| 343 | |
| 344 | Text( |
| 345 | "Use your email and password to sign in, resend your confirmation email, or reset your password." |
| 346 | ) |
| 347 | .font(KleponTypography.bodySecondary) |
| 348 | .foregroundStyle(KleponColor.textSecondary) |
| 349 | |
| 350 | AccountInputField( |
| 351 | title: "Email", |
| 352 | placeholder: "you@example.com", |
| 353 | text: $email, |
| 354 | textContentType: .username, |
| 355 | keyboard: .emailAddress |
| 356 | ) |
| 357 | |
| 358 | AccountInputField( |
| 359 | title: "Password", |
| 360 | placeholder: "Enter your password", |
| 361 | text: $password, |
| 362 | isSecure: true, |
| 363 | textContentType: .password |
| 364 | ) |
| 365 | |
| 366 | KleponActionButton( |
| 367 | title: "Log in", |
| 368 | systemImage: "arrow.right.circle.fill", |
| 369 | isLoading: isBusy, |
| 370 | isDisabled: !canSubmit, |
| 371 | action: onSubmit |
| 372 | ) |
| 373 | |
| 374 | VStack(spacing: 10) { |
| 375 | KleponActionButton( |
| 376 | title: "Resend confirmation email", |
| 377 | systemImage: "envelope.badge", |
| 378 | tone: .secondary, |
| 379 | isDisabled: isBusy || !canRequestEmailAction, |
| 380 | action: onResendConfirmation |
| 381 | ) |
| 382 | |
| 383 | KleponActionButton( |
| 384 | title: "Forgot password", |
| 385 | systemImage: "key", |
| 386 | tone: .secondary, |
| 387 | isDisabled: isBusy || !canRequestEmailAction, |
| 388 | action: onForgotPassword |
| 389 | ) |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | private struct AccountSignupCard: View { |
| 397 | @Binding var email: String |
| 398 | @Binding var password: String |
| 399 | @Binding var passwordConfirmation: String |
| 400 | |
| 401 | let isBusy: Bool |
| 402 | let canSubmit: Bool |
| 403 | let onSubmit: () -> Void |
| 404 | |
| 405 | private var passwordsMatch: Bool { |
| 406 | !password.isEmpty && password == passwordConfirmation |
| 407 | } |
| 408 | |
| 409 | var body: some View { |
| 410 | KleponCard { |
| 411 | VStack(alignment: .leading, spacing: 14) { |
| 412 | Text("Sign up") |
| 413 | .font(KleponTypography.cardTitle) |
| 414 | .foregroundStyle(KleponColor.textPrimary) |
| 415 | |
| 416 | Text( |
| 417 | "Create your account, then confirm your email before your first sign in." |
| 418 | ) |
| 419 | .font(KleponTypography.bodySecondary) |
| 420 | .foregroundStyle(KleponColor.textSecondary) |
| 421 | |
| 422 | AccountInputField( |
| 423 | title: "Email", |
| 424 | placeholder: "new-user@example.com", |
| 425 | text: $email, |
| 426 | textContentType: .username, |
| 427 | keyboard: .emailAddress |
| 428 | ) |
| 429 | |
| 430 | AccountInputField( |
| 431 | title: "Password", |
| 432 | placeholder: "Choose a password", |
| 433 | text: $password, |
| 434 | isSecure: true, |
| 435 | textContentType: .newPassword |
| 436 | ) |
| 437 | |
| 438 | AccountInputField( |
| 439 | title: "Confirm password", |
| 440 | placeholder: "Type it again", |
| 441 | text: $passwordConfirmation, |
| 442 | isSecure: true, |
| 443 | textContentType: .newPassword |
| 444 | ) |
| 445 | |
| 446 | if !passwordConfirmation.isEmpty { |
| 447 | Label( |
| 448 | passwordsMatch ? "Passwords match" : "Passwords must match", |
| 449 | systemImage: passwordsMatch |
| 450 | ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" |
| 451 | ) |
| 452 | .font(KleponTypography.caption) |
| 453 | .foregroundStyle(passwordsMatch ? KleponColor.accent : KleponColor.highlight) |
| 454 | } |
| 455 | |
| 456 | KleponActionButton( |
| 457 | title: "Create account", |
| 458 | systemImage: "person.badge.plus", |
| 459 | isLoading: isBusy, |
| 460 | isDisabled: !canSubmit, |
| 461 | action: onSubmit |
| 462 | ) |
| 463 | } |
| 464 | } |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | private struct AccountProfileCard: View { |
| 469 | let user: AccountUser? |
| 470 | let isBusy: Bool |
| 471 | let onRefresh: () -> Void |
| 472 | let onSaveProfile: (String, String) async -> Bool |
| 473 | let onUpdatePassword: (String, String, String) async -> Bool |
| 474 | let onLogout: () -> Void |
| 475 | let onRemoveAccount: () -> Void |
| 476 | |
| 477 | @State private var isEditingProfile = false |
| 478 | @State private var name = "" |
| 479 | @State private var username = "" |
| 480 | @State private var currentPassword = "" |
| 481 | @State private var newPassword = "" |
| 482 | @State private var newPasswordConfirmation = "" |
| 483 | |
| 484 | var body: some View { |
| 485 | KleponCard { |
| 486 | VStack(alignment: .leading, spacing: 14) { |
| 487 | HStack(alignment: .center) { |
| 488 | Text("Profile") |
| 489 | .font(KleponTypography.cardTitle) |
| 490 | .foregroundStyle(KleponColor.textPrimary) |
| 491 | |
| 492 | Spacer() |
| 493 | |
| 494 | if user != nil && !isEditingProfile { |
| 495 | Button("Edit") { |
| 496 | resetDraftValues() |
| 497 | isEditingProfile = true |
| 498 | } |
| 499 | .font(KleponTypography.caption.weight(.semibold)) |
| 500 | .foregroundStyle(KleponColor.accent) |
| 501 | .disabled(isBusy) |
| 502 | } |
| 503 | } |
| 504 | |
| 505 | if let user { |
| 506 | KleponMetadataRow( |
| 507 | title: "Name", |
| 508 | value: user.name?.isEmpty == false ? (user.name ?? "Not set") : "Not set" |
| 509 | ) |
| 510 | KleponMetadataRow( |
| 511 | title: "Username", |
| 512 | value: user.usernameDisplay ?? "Not set" |
| 513 | ) |
| 514 | KleponMetadataRow(title: "Email", value: user.email) |
| 515 | KleponMetadataRow(title: "User ID", value: "\(user.id)") |
| 516 | KleponMetadataRow(title: "Created", value: user.createdAt) |
| 517 | KleponMetadataRow(title: "Updated", value: user.updatedAt) |
| 518 | } else { |
| 519 | Text("Klepon is still loading the profile details for this session.") |
| 520 | .font(KleponTypography.bodySecondary) |
| 521 | .foregroundStyle(KleponColor.textSecondary) |
| 522 | } |
| 523 | |
| 524 | if isEditingProfile { |
| 525 | AccountInputField( |
| 526 | title: "Name", |
| 527 | placeholder: "Your name", |
| 528 | text: $name |
| 529 | ) |
| 530 | |
| 531 | AccountInputField( |
| 532 | title: "Username", |
| 533 | placeholder: "yourname", |
| 534 | text: $username |
| 535 | ) |
| 536 | |
| 537 | Text( |
| 538 | "Use lowercase letters, numbers, dots, dashes, and underscores for your username." |
| 539 | ) |
| 540 | .font(KleponTypography.caption) |
| 541 | .foregroundStyle(KleponColor.textSecondary) |
| 542 | |
| 543 | HStack(spacing: 10) { |
| 544 | KleponActionButton( |
| 545 | title: "Save profile", |
| 546 | systemImage: "checkmark.circle.fill", |
| 547 | isLoading: isBusy, |
| 548 | action: { |
| 549 | Task { |
| 550 | let didSave = await onSaveProfile(name, username) |
| 551 | if didSave { |
| 552 | isEditingProfile = false |
| 553 | } |
| 554 | } |
| 555 | } |
| 556 | ) |
| 557 | |
| 558 | KleponActionButton( |
| 559 | title: "Cancel", |
| 560 | systemImage: "xmark.circle", |
| 561 | tone: .secondary, |
| 562 | isDisabled: isBusy, |
| 563 | action: { |
| 564 | resetDraftValues() |
| 565 | isEditingProfile = false |
| 566 | } |
| 567 | ) |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | if user != nil { |
| 572 | VStack(alignment: .leading, spacing: 12) { |
| 573 | Text("Password") |
| 574 | .font(KleponTypography.bodySecondary.weight(.semibold)) |
| 575 | .foregroundStyle(KleponColor.textPrimary) |
| 576 | |
| 577 | if let passwordRulesSummary = user?.passwordRulesSummary, |
| 578 | !passwordRulesSummary.isEmpty |
| 579 | { |
| 580 | Text("Use \(passwordRulesSummary).") |
| 581 | .font(KleponTypography.caption) |
| 582 | .foregroundStyle(KleponColor.textSecondary) |
| 583 | } |
| 584 | |
| 585 | AccountInputField( |
| 586 | title: "Current password", |
| 587 | placeholder: "Your current password", |
| 588 | text: $currentPassword, |
| 589 | isSecure: true, |
| 590 | textContentType: .password |
| 591 | ) |
| 592 | |
| 593 | AccountInputField( |
| 594 | title: "New password", |
| 595 | placeholder: "Choose a new password", |
| 596 | text: $newPassword, |
| 597 | isSecure: true, |
| 598 | textContentType: .newPassword |
| 599 | ) |
| 600 | |
| 601 | AccountInputField( |
| 602 | title: "Confirm new password", |
| 603 | placeholder: "Type it again", |
| 604 | text: $newPasswordConfirmation, |
| 605 | isSecure: true, |
| 606 | textContentType: .newPassword |
| 607 | ) |
| 608 | |
| 609 | if !newPasswordConfirmation.isEmpty |
| 610 | && newPassword != newPasswordConfirmation |
| 611 | { |
| 612 | Label( |
| 613 | "New password and confirmation must match", |
| 614 | systemImage: "exclamationmark.triangle.fill" |
| 615 | ) |
| 616 | .font(KleponTypography.caption) |
| 617 | .foregroundStyle(KleponColor.highlight) |
| 618 | } |
| 619 | |
| 620 | KleponActionButton( |
| 621 | title: "Update password", |
| 622 | systemImage: "key.fill", |
| 623 | tone: .secondary, |
| 624 | isLoading: isBusy, |
| 625 | isDisabled: currentPassword.isEmpty || newPassword.isEmpty |
| 626 | || newPasswordConfirmation.isEmpty |
| 627 | || newPassword != newPasswordConfirmation, |
| 628 | action: { |
| 629 | Task { |
| 630 | let didUpdate = await onUpdatePassword( |
| 631 | currentPassword, |
| 632 | newPassword, |
| 633 | newPasswordConfirmation |
| 634 | ) |
| 635 | if didUpdate { |
| 636 | currentPassword = "" |
| 637 | newPassword = "" |
| 638 | newPasswordConfirmation = "" |
| 639 | } |
| 640 | } |
| 641 | } |
| 642 | ) |
| 643 | } |
| 644 | } |
| 645 | |
| 646 | HStack(spacing: 10) { |
| 647 | KleponActionButton( |
| 648 | title: "Refresh profile", |
| 649 | systemImage: "arrow.clockwise", |
| 650 | tone: .secondary, |
| 651 | isLoading: isBusy, |
| 652 | isDisabled: user == nil, |
| 653 | action: onRefresh |
| 654 | ) |
| 655 | |
| 656 | KleponActionButton( |
| 657 | title: "Log out", |
| 658 | systemImage: "rectangle.portrait.and.arrow.right", |
| 659 | tone: .secondary, |
| 660 | isDisabled: isBusy, |
| 661 | action: onLogout |
| 662 | ) |
| 663 | } |
| 664 | |
| 665 | KleponActionButton( |
| 666 | title: "Delete account", |
| 667 | systemImage: "trash", |
| 668 | tone: .secondary, |
| 669 | isDisabled: isBusy, |
| 670 | action: onRemoveAccount |
| 671 | ) |
| 672 | } |
| 673 | .onAppear { |
| 674 | resetDraftValues() |
| 675 | } |
| 676 | .onChange(of: user?.updatedAt ?? "") { _, _ in |
| 677 | resetDraftValues() |
| 678 | } |
| 679 | } |
| 680 | } |
| 681 | |
| 682 | private func resetDraftValues() { |
| 683 | name = user?.name ?? "" |
| 684 | username = user?.username ?? "" |
| 685 | } |
| 686 | } |
| 687 | |
| 688 | private struct AccountInputField: View { |
| 689 | let title: String |
| 690 | let placeholder: String |
| 691 | @Binding var text: String |
| 692 | var isSecure = false |
| 693 | var textContentType: AccountTextContentType? = nil |
| 694 | var keyboard: AccountKeyboardType? = nil |
| 695 | |
| 696 | var body: some View { |
| 697 | VStack(alignment: .leading, spacing: 8) { |
| 698 | Text(title) |
| 699 | .font(KleponTypography.caption) |
| 700 | .foregroundStyle(KleponColor.accentWarm) |
| 701 | |
| 702 | Group { |
| 703 | if isSecure { |
| 704 | SecureField(placeholder, text: $text) |
| 705 | } else { |
| 706 | TextField(placeholder, text: $text) |
| 707 | } |
| 708 | } |
| 709 | .font(KleponTypography.body) |
| 710 | .foregroundColor(KleponColor.textPrimary) |
| 711 | .applyPlainTextFieldStyleForPlatform() |
| 712 | .padding(.horizontal, 14) |
| 713 | .padding(.vertical, 12) |
| 714 | .background( |
| 715 | RoundedRectangle(cornerRadius: 18, style: .continuous) |
| 716 | .fill(KleponColor.surfaceSecondary) |
| 717 | ) |
| 718 | .overlay( |
| 719 | RoundedRectangle(cornerRadius: 18, style: .continuous) |
| 720 | .stroke(KleponColor.divider, lineWidth: 1) |
| 721 | ) |
| 722 | .applyAccountInputTraits(textContentType: textContentType, keyboard: keyboard) |
| 723 | .disableAutocorrectionForPlatform() |
| 724 | .disableAutocapitalizationForPlatform() |
| 725 | } |
| 726 | } |
| 727 | } |
| 728 | |
| 729 | extension View { |
| 730 | @ViewBuilder |
| 731 | fileprivate func modifyIfLet<Value, Modified: View>( |
| 732 | _ value: Value?, |
| 733 | @ViewBuilder transform: (Self, Value) -> Modified |
| 734 | ) -> some View { |
| 735 | if let value { |
| 736 | transform(self, value) |
| 737 | } else { |
| 738 | self |
| 739 | } |
| 740 | } |
| 741 | |
| 742 | @ViewBuilder |
| 743 | fileprivate func applyAccountInputTraits( |
| 744 | textContentType: AccountTextContentType?, |
| 745 | keyboard: AccountKeyboardType? |
| 746 | ) -> some View { |
| 747 | #if os(iOS) || os(tvOS) || os(visionOS) |
| 748 | self |
| 749 | .modifyIfLet(textContentType) { view, textContentType in |
| 750 | switch textContentType { |
| 751 | case .username: |
| 752 | view.textContentType(.username) |
| 753 | case .password: |
| 754 | view.textContentType(.password) |
| 755 | case .newPassword: |
| 756 | view.textContentType(.newPassword) |
| 757 | } |
| 758 | } |
| 759 | .modifyIfLet(keyboard) { view, keyboard in |
| 760 | view.keyboardType(keyboard) |
| 761 | } |
| 762 | #else |
| 763 | self |
| 764 | #endif |
| 765 | } |
| 766 | |
| 767 | @ViewBuilder |
| 768 | fileprivate func disableAutocorrectionForPlatform() -> some View { |
| 769 | #if os(iOS) || os(tvOS) || os(visionOS) |
| 770 | self.autocorrectionDisabled() |
| 771 | #else |
| 772 | self |
| 773 | #endif |
| 774 | } |
| 775 | |
| 776 | @ViewBuilder |
| 777 | fileprivate func disableAutocapitalizationForPlatform() -> some View { |
| 778 | #if os(iOS) || os(tvOS) || os(visionOS) |
| 779 | self.textInputAutocapitalization(.never) |
| 780 | #else |
| 781 | self |
| 782 | #endif |
| 783 | } |
| 784 | |
| 785 | @ViewBuilder |
| 786 | fileprivate func applyPlainTextFieldStyleForPlatform() -> some View { |
| 787 | #if os(macOS) |
| 788 | self.textFieldStyle(.plain) |
| 789 | #else |
| 790 | self |
| 791 | #endif |
| 792 | } |
| 793 | } |