main
swift 793 lines 28.6 KB
Raw
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 }