- Completion animations: play user-selected animation on task card after completing, with DataManager guard to prevent race condition during animation playback. Works in both AllTasksView and ResidenceDetailView. Animation preference persisted via @AppStorage and configurable from Settings. - Subscription: add trial fields (trialStart, trialEnd, trialActive) and subscriptionSource to model, cross-platform purchase guard, trial banner in upgrade prompt, and platform-aware subscription management in profile. - Analytics: disable PostHog SDK debug logging and remove console print statements to reduce debug console noise. - Documents: remove redundant nested do-catch blocks in ViewModel wrapper. - Widgets: add debounced timeline reloads and thread-safe file I/O queue. - Onboarding: fix animation leak on disappear, remove unused state vars. - Remove unused files (ContentView, StateFlowExtensions, CustomView). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
378 lines
16 KiB
Swift
378 lines
16 KiB
Swift
import SwiftUI
|
|
import ComposeApp
|
|
|
|
struct RegisterView: View {
|
|
@StateObject private var viewModel = RegisterViewModel()
|
|
@Environment(\.dismiss) var dismiss
|
|
@FocusState private var focusedField: Field?
|
|
@State private var showVerifyEmail = false
|
|
@State private var isPasswordVisible = false
|
|
@State private var isConfirmPasswordVisible = false
|
|
|
|
enum Field {
|
|
case username, email, password, confirmPassword
|
|
}
|
|
|
|
private var isFormValid: Bool {
|
|
!viewModel.username.isEmpty &&
|
|
!viewModel.email.isEmpty &&
|
|
!viewModel.password.isEmpty &&
|
|
!viewModel.confirmPassword.isEmpty
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
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: "person.badge.plus")
|
|
.font(.system(size: 48, weight: .medium))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
|
|
VStack(spacing: 8) {
|
|
Text(L10n.Auth.joinCasera)
|
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
Text(L10n.Auth.startManaging)
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
}
|
|
|
|
// Registration Card
|
|
VStack(spacing: 20) {
|
|
// Username Field
|
|
OrganicTextField(
|
|
label: L10n.Auth.accountInfo,
|
|
placeholder: L10n.Auth.registerUsername,
|
|
text: $viewModel.username,
|
|
icon: "person.fill",
|
|
isFocused: focusedField == .username,
|
|
accessibilityId: AccessibilityIdentifiers.Authentication.registerUsernameField
|
|
)
|
|
.focused($focusedField, equals: .username)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.textContentType(.username)
|
|
.submitLabel(.next)
|
|
.onSubmit { focusedField = .email }
|
|
|
|
// Email Field
|
|
OrganicTextField(
|
|
label: nil,
|
|
placeholder: L10n.Auth.registerEmail,
|
|
text: $viewModel.email,
|
|
icon: "envelope.fill",
|
|
isFocused: focusedField == .email,
|
|
accessibilityId: AccessibilityIdentifiers.Authentication.registerEmailField
|
|
)
|
|
.focused($focusedField, equals: .email)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.keyboardType(.emailAddress)
|
|
.textContentType(.emailAddress)
|
|
.submitLabel(.next)
|
|
.onSubmit { focusedField = .password }
|
|
|
|
OrganicDivider()
|
|
.padding(.vertical, 4)
|
|
|
|
// Password Field
|
|
OrganicSecureField(
|
|
label: L10n.Auth.security,
|
|
placeholder: L10n.Auth.registerPassword,
|
|
text: $viewModel.password,
|
|
isVisible: $isPasswordVisible,
|
|
isFocused: focusedField == .password,
|
|
accessibilityId: AccessibilityIdentifiers.Authentication.registerPasswordField
|
|
)
|
|
.focused($focusedField, equals: .password)
|
|
.textContentType(.newPassword)
|
|
.submitLabel(.next)
|
|
.onSubmit { focusedField = .confirmPassword }
|
|
|
|
// Confirm Password Field
|
|
OrganicSecureField(
|
|
label: nil,
|
|
placeholder: L10n.Auth.registerConfirmPassword,
|
|
text: $viewModel.confirmPassword,
|
|
isVisible: $isConfirmPasswordVisible,
|
|
isFocused: focusedField == .confirmPassword,
|
|
accessibilityId: AccessibilityIdentifiers.Authentication.registerConfirmPasswordField
|
|
)
|
|
.focused($focusedField, equals: .confirmPassword)
|
|
.textContentType(.newPassword)
|
|
.submitLabel(.go)
|
|
.onSubmit { viewModel.register() }
|
|
|
|
Text(L10n.Auth.passwordSuggestion)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
// 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))
|
|
}
|
|
|
|
// Register Button
|
|
Button(action: viewModel.register) {
|
|
HStack(spacing: 8) {
|
|
if viewModel.isLoading {
|
|
ProgressView()
|
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
}
|
|
Text(viewModel.isLoading ? L10n.Auth.creatingAccount : L10n.Auth.registerButton)
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 56)
|
|
.foregroundColor(Color.appTextOnPrimary)
|
|
.background(
|
|
isFormValid && !viewModel.isLoading
|
|
? 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)
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton)
|
|
|
|
// Login Link
|
|
HStack(spacing: 6) {
|
|
Text(L10n.Auth.alreadyHaveAccount)
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
|
|
Button(L10n.Auth.signIn) {
|
|
dismiss()
|
|
}
|
|
.font(.system(size: 15, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
.padding(.top, 8)
|
|
}
|
|
.padding(OrganicSpacing.cozy)
|
|
.background(OrganicFormBackground())
|
|
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
|
.naturalShadow(.pronounced)
|
|
.padding(.horizontal, 16)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button(action: { dismiss() }) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
.padding(8)
|
|
.background(Color.appBackgroundSecondary.opacity(0.8))
|
|
.clipShape(Circle())
|
|
}
|
|
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerCancelButton)
|
|
}
|
|
}
|
|
.fullScreenCover(isPresented: $viewModel.isRegistered) {
|
|
VerifyEmailView(
|
|
onVerifySuccess: {
|
|
AuthenticationManager.shared.markVerified()
|
|
showVerifyEmail = false
|
|
dismiss()
|
|
},
|
|
onLogout: {
|
|
AuthenticationManager.shared.logout()
|
|
dismiss()
|
|
}
|
|
)
|
|
}
|
|
.onAppear {
|
|
AnalyticsManager.shared.trackScreen(.registration)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Organic Text Field
|
|
|
|
private struct OrganicTextField: View {
|
|
let label: String?
|
|
let placeholder: String
|
|
@Binding var text: String
|
|
let icon: String
|
|
var isFocused: Bool = false
|
|
var accessibilityId: String? = nil
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if let label = label {
|
|
Text(label.uppercased())
|
|
.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: icon)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(Color.appPrimary)
|
|
}
|
|
|
|
TextField(placeholder, text: $text)
|
|
.font(.system(size: 16, weight: .medium))
|
|
.accessibilityIdentifier(accessibilityId ?? "")
|
|
}
|
|
.padding(16)
|
|
.background(Color.appBackgroundPrimary.opacity(0.5))
|
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
|
)
|
|
.animation(.easeInOut(duration: 0.2), value: isFocused)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Organic Secure Field
|
|
|
|
private struct OrganicSecureField: View {
|
|
let label: String?
|
|
let placeholder: String
|
|
@Binding var text: String
|
|
@Binding var isVisible: Bool
|
|
var isFocused: Bool = false
|
|
var accessibilityId: String? = nil
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if let label = label {
|
|
Text(label.uppercased())
|
|
.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 isVisible {
|
|
TextField(placeholder, text: $text)
|
|
.accessibilityIdentifier(accessibilityId ?? "")
|
|
} else {
|
|
SecureField(placeholder, text: $text)
|
|
.accessibilityIdentifier(accessibilityId ?? "")
|
|
}
|
|
}
|
|
.font(.system(size: 16, weight: .medium))
|
|
|
|
Button(action: { isVisible.toggle() }) {
|
|
Image(systemName: isVisible ? "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(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
|
)
|
|
.animation(.easeInOut(duration: 0.2), value: isFocused)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Organic Form Background
|
|
|
|
private struct OrganicFormBackground: View {
|
|
@Environment(\.colorScheme) var colorScheme
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.appBackgroundSecondary
|
|
|
|
GeometryReader { geo in
|
|
OrganicBlobShape(variation: 1)
|
|
.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.6, height: geo.size.height * 0.4)
|
|
.offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.05)
|
|
.blur(radius: 20)
|
|
}
|
|
|
|
GrainTexture(opacity: 0.015)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
RegisterView()
|
|
}
|