Files
honeyDueKMP/iosApp/iosApp/Register/RegisterView.swift
Trey t 98dbacdea0 Add task completion animations, subscription trials, and quiet debug console
- 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>
2026-03-05 11:35:08 -06:00

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()
}