From b05e52521fd9dcc1075006be97115c71c7e8d2fc Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 17 Dec 2025 09:05:47 -0600 Subject: [PATCH] Apply Warm Organic design system to all iOS views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Contractor/ContractorDetailView.swift | 2 +- .../Contractor/ContractorsListView.swift | 450 ++++++++---- .../Documents/Components/EmptyStateView.swift | 24 +- .../iosApp/Documents/DocumentDetailView.swift | 104 ++- .../iosApp/Documents/DocumentFormView.swift | 2 +- .../Documents/DocumentsWarrantiesView.swift | 198 ++++-- iosApp/iosApp/MainTabView.swift | 45 +- .../OnboardingCreateAccountView.swift | 478 ++++++++----- .../Onboarding/OnboardingFirstTaskView.swift | 477 +++++++------ .../OnboardingJoinResidenceView.swift | 312 ++++++--- .../OnboardingNameResidenceView.swift | 408 ++++++----- .../OnboardingSubscriptionView.swift | 444 ++++++------ .../Onboarding/OnboardingValuePropsView.swift | 231 ++++--- .../OnboardingVerifyEmailView.swift | 368 ++++++---- .../Onboarding/OnboardingWelcomeView.swift | 236 +++++-- .../PasswordReset/ForgotPasswordView.swift | 295 +++++--- .../PasswordReset/ResetPasswordView.swift | 580 +++++++++------- .../PasswordReset/VerifyResetCodeView.swift | 359 ++++++---- iosApp/iosApp/Register/RegisterView.swift | 430 +++++++++--- .../iosApp/Residence/JoinResidenceView.swift | 227 +++++-- iosApp/iosApp/Residence/ManageUsersView.swift | 4 +- iosApp/iosApp/ResidenceFormView.swift | 640 +++++++++++++----- .../Subscription/FeatureComparisonView.swift | 2 +- .../Subscription/UpgradeFeatureView.swift | 266 +++++--- .../Subscription/UpgradePromptView.swift | 476 ++++++++----- iosApp/iosApp/Subviews/Common/ErrorView.swift | 25 +- iosApp/iosApp/Subviews/Common/StatView.swift | 17 +- .../Residence/EmptyResidencesView.swift | 19 +- .../Subviews/Residence/SummaryStatView.swift | 19 +- .../Subviews/Task/CompletionCardView.swift | 8 +- .../Subviews/Task/DynamicTaskColumnView.swift | 9 +- .../iosApp/Subviews/Task/EmptyTasksView.swift | 21 +- iosApp/iosApp/Task/AllTasksView.swift | 281 +++++--- iosApp/iosApp/Task/CompleteTaskView.swift | 26 +- iosApp/iosApp/Task/TaskSuggestionsView.swift | 4 +- .../Task/TaskTemplatesBrowserView.swift | 2 +- .../iosApp/VerifyEmail/VerifyEmailView.swift | 279 +++++--- 37 files changed, 5009 insertions(+), 2759 deletions(-) diff --git a/iosApp/iosApp/Contractor/ContractorDetailView.swift b/iosApp/iosApp/Contractor/ContractorDetailView.swift index 884f0e7..01756db 100644 --- a/iosApp/iosApp/Contractor/ContractorDetailView.swift +++ b/iosApp/iosApp/Contractor/ContractorDetailView.swift @@ -18,7 +18,7 @@ struct ContractorDetailView: View { var body: some View { ZStack { - Color.appBackgroundPrimary.ignoresSafeArea() + WarmGradientBackground() contentStateView } .onAppear { diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index 2252109..e04cd6d 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -12,7 +12,6 @@ struct ContractorsListView: View { @State private var showSpecialtyFilter = false @State private var showingUpgradePrompt = false - // Lookups from DataManagerObservable private var contractorSpecialties: [ContractorSpecialty] { dataManager.contractorSpecialties } var specialties: [String] { @@ -23,7 +22,6 @@ struct ContractorsListView: View { viewModel.contractors } - // Client-side filtering since backend doesn't support search/filter params var filteredContractors: [ContractorSummary] { contractors.filter { contractor in let matchesSearch = searchText.isEmpty || @@ -36,59 +34,58 @@ struct ContractorsListView: View { } } - // Check if upgrade screen should be shown (disables add button) private var shouldShowUpgrade: Bool { subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors") } var body: some View { ZStack { - Color.appBackgroundPrimary.ignoresSafeArea() + WarmGradientBackground() VStack(spacing: 0) { // Search Bar - SearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder) - .padding(.horizontal, AppSpacing.md) - .padding(.top, AppSpacing.sm) + OrganicSearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder) + .padding(.horizontal, 16) + .padding(.top, 8) - // Active Filters - if showFavoritesOnly || selectedSpecialty != nil { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: AppSpacing.xs) { - if showFavoritesOnly { - FilterChip( - title: L10n.Contractors.favorites, - icon: "star.fill", - onRemove: { showFavoritesOnly = false } - ) - } - - if let specialty = selectedSpecialty { - FilterChip( - title: specialty, - onRemove: { selectedSpecialty = nil } - ) - } + // Active Filters + if showFavoritesOnly || selectedSpecialty != nil { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + if showFavoritesOnly { + OrganicFilterChip( + title: L10n.Contractors.favorites, + icon: "star.fill", + onRemove: { showFavoritesOnly = false } + ) } - .padding(.horizontal, AppSpacing.md) - } - .padding(.vertical, AppSpacing.xs) - } - // Content - use filteredContractors for client-side filtering + if let specialty = selectedSpecialty { + OrganicFilterChip( + title: specialty, + onRemove: { selectedSpecialty = nil } + ) + } + } + .padding(.horizontal, 16) + } + .padding(.vertical, 8) + } + + // Content ListAsyncContentView( items: filteredContractors, isLoading: viewModel.isLoading, errorMessage: viewModel.errorMessage, content: { contractorList in - ContractorsContent( + OrganicContractorsContent( contractors: contractorList, onToggleFavorite: toggleFavorite ) }, emptyContent: { if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") { - EmptyContractorsView( + OrganicEmptyContractorsView( hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty ) } else { @@ -107,82 +104,77 @@ struct ContractorsListView: View { ) } } - .navigationTitle(L10n.Contractors.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: AppSpacing.sm) { - // Favorites Filter (client-side, no API call needed) - Button(action: { - showFavoritesOnly.toggle() - }) { - Image(systemName: showFavoritesOnly ? "star.fill" : "star") - .foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary) - } - - // Specialty Filter (client-side, no API call needed) - Menu { - Button(action: { - selectedSpecialty = nil - }) { - Label(L10n.Contractors.allSpecialties, systemImage: selectedSpecialty == nil ? "checkmark" : "") - } - - Divider() - - ForEach(specialties, id: \.self) { specialty in - Button(action: { - selectedSpecialty = specialty - }) { - Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "") - } - } - } label: { - Image(systemName: "line.3.horizontal.decrease.circle") - .foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary) - } - - // Add Button (disabled when showing upgrade screen) - Button(action: { - let currentCount = viewModel.contractors.count - if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") { - // Track paywall shown - PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount]) - showingUpgradePrompt = true - } else { - showingAddSheet = true - } - }) { - Image(systemName: "plus.circle.fill") - .font(.title2) - .foregroundColor(Color.appPrimary) - } - .accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: 12) { + // Favorites Filter + Button(action: { + showFavoritesOnly.toggle() + }) { + Image(systemName: showFavoritesOnly ? "star.fill" : "star") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary) } + + // Specialty Filter + Menu { + Button(action: { + selectedSpecialty = nil + }) { + Label(L10n.Contractors.allSpecialties, systemImage: selectedSpecialty == nil ? "checkmark" : "") + } + + Divider() + + ForEach(specialties, id: \.self) { specialty in + Button(action: { + selectedSpecialty = specialty + }) { + Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "") + } + } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary) + } + + // Add Button + Button(action: { + let currentCount = viewModel.contractors.count + if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") { + PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount]) + showingUpgradePrompt = true + } else { + showingAddSheet = true + } + }) { + OrganicToolbarButton(systemName: "plus", isPrimary: true) + } + .accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton) } } - .sheet(isPresented: $showingAddSheet) { - ContractorFormSheet( - contractor: nil, - onSave: { - loadContractors() - } - ) - .presentationDetents([.large]) - } - .sheet(isPresented: $showingUpgradePrompt) { - UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt) - } - .onAppear { - PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown) - loadContractors() - } - // No need for onChange on searchText - filtering is client-side - // Contractor specialties are loaded from DataManagerObservable + } + .sheet(isPresented: $showingAddSheet) { + ContractorFormSheet( + contractor: nil, + onSave: { + loadContractors() + } + ) + .presentationDetents([.large]) + } + .sheet(isPresented: $showingUpgradePrompt) { + UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt) + } + .onAppear { + PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown) + loadContractors() + } } private func loadContractors(forceRefresh: Bool = false) { - // Load all contractors, filtering is done client-side viewModel.loadContractors(forceRefresh: forceRefresh) } @@ -195,73 +187,82 @@ struct ContractorsListView: View { } } -// MARK: - Search Bar -struct SearchBar: View { +// MARK: - Organic Search Bar + +private struct OrganicSearchBar: View { @Binding var text: String var placeholder: String var body: some View { - HStack(spacing: AppSpacing.sm) { - Image(systemName: "magnifyingglass") - .foregroundColor(Color.appTextSecondary) + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 32, height: 32) + Image(systemName: "magnifyingglass") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appPrimary) + } TextField(placeholder, text: $text) - .font(.body) + .font(.system(size: 16, weight: .medium)) if !text.isEmpty { Button(action: { text = "" }) { Image(systemName: "xmark.circle.fill") + .font(.system(size: 18)) .foregroundColor(Color.appTextSecondary) } } } - .padding(AppSpacing.sm) + .padding(14) .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.md) - .shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) } } -// MARK: - Filter Chip -struct FilterChip: View { +// MARK: - Organic Filter Chip + +private struct OrganicFilterChip: View { let title: String var icon: String? = nil let onRemove: () -> Void var body: some View { - HStack(spacing: AppSpacing.xxs) { + HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) - .font(.caption) + .font(.system(size: 12, weight: .semibold)) } Text(title) - .font(.footnote.weight(.medium)) + .font(.system(size: 13, weight: .semibold)) Button(action: onRemove) { Image(systemName: "xmark") - .font(.caption2) + .font(.system(size: 10, weight: .bold)) } } - .padding(.horizontal, AppSpacing.sm) - .padding(.vertical, AppSpacing.xxs) - .background(Color.appPrimary.opacity(0.1)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.appPrimary.opacity(0.15)) .foregroundColor(Color.appPrimary) - .cornerRadius(AppRadius.full) + .clipShape(Capsule()) } } -// MARK: - Contractors Content +// MARK: - Organic Contractors Content -private struct ContractorsContent: View { +private struct OrganicContractorsContent: View { let contractors: [ContractorSummary] let onToggleFavorite: (Int32) -> Void var body: some View { - ScrollView { - LazyVStack(spacing: AppSpacing.sm) { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 12) { ForEach(contractors, id: \.id) { contractor in NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) { - ContractorCard( + OrganicContractorCard( contractor: contractor, onToggleFavorite: { onToggleFavorite(contractor.id) @@ -271,8 +272,8 @@ private struct ContractorsContent: View { .buttonStyle(PlainButtonStyle()) } } - .padding(AppSpacing.md) - .padding(.bottom, AppSpacing.xxxl) + .padding(16) + .padding(.bottom, 40) } .safeAreaInset(edge: .bottom) { Color.clear.frame(height: 0) @@ -280,32 +281,189 @@ private struct ContractorsContent: View { } } -// MARK: - Empty State -struct EmptyContractorsView: View { - let hasFilters: Bool +// MARK: - Organic Contractor Card + +private struct OrganicContractorCard: View { + let contractor: ContractorSummary + let onToggleFavorite: () -> Void + @Environment(\.colorScheme) var colorScheme var body: some View { - VStack(spacing: AppSpacing.md) { - Image(systemName: "person.badge.plus") - .font(.system(size: 64)) - .foregroundColor(Color.appTextSecondary.opacity(0.7)) + HStack(spacing: 14) { + // Avatar + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 50, height: 50) - Text(hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle) - .font(.title3.weight(.semibold)) - .foregroundColor(Color.appTextSecondary) - - if !hasFilters { - Text(L10n.Contractors.emptyNoFilters) - .font(.callout) - .foregroundColor(Color.appTextSecondary.opacity(0.7)) + Text(String(contractor.name.prefix(1)).uppercased()) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(Color.appTextOnPrimary) } + + VStack(alignment: .leading, spacing: 4) { + Text(contractor.name) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.appTextPrimary) + .lineLimit(1) + + if let company = contractor.company, !company.isEmpty { + Text(company) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .lineLimit(1) + } + + if !contractor.specialties.isEmpty { + HStack(spacing: 4) { + ForEach(contractor.specialties.prefix(2), id: \.id) { specialty in + Text(specialty.name) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color.appPrimary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.appPrimary.opacity(0.1)) + .clipShape(Capsule()) + } + if contractor.specialties.count > 2 { + Text("+\(contractor.specialties.count - 2)") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color.appTextSecondary) + } + } + } + } + + Spacer() + + // Favorite Button + Button(action: onToggleFavorite) { + Image(systemName: contractor.isFavorite ? "star.fill" : "star") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary) + } + .buttonStyle(.plain) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color.appTextSecondary.opacity(0.5)) } - .padding(AppSpacing.xl) + .padding(16) + .background( + ZStack { + Color.appBackgroundSecondary + + GeometryReader { geo in + OrganicBlobShape(variation: 1) + .fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.04 : 0.02)) + .frame(width: geo.size.width * 0.4, height: geo.size.height * 0.8) + .offset(x: geo.size.width * 0.6, y: 0) + .blur(radius: 10) + } + + GrainTexture(opacity: 0.01) + } + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .naturalShadow(.medium) } } -struct ContractorsListView_Previews: PreviewProvider { - static var previews: some View { +// MARK: - Organic Toolbar Button + +private struct OrganicToolbarButton: View { + let systemName: String + var isPrimary: Bool = false + + var body: some View { + ZStack { + if isPrimary { + Circle() + .fill(Color.appPrimary) + .frame(width: 32, height: 32) + + Image(systemName: systemName) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(Color.appTextOnPrimary) + } else { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 32, height: 32) + + Image(systemName: systemName) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + } + } +} + +// MARK: - Organic Empty Contractors View + +private struct OrganicEmptyContractorsView: View { + let hasFilters: Bool + @State private var isAnimating = false + + var body: some View { + VStack(spacing: OrganicSpacing.comfortable) { + Spacer() + + 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) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + Animation.easeInOut(duration: 3).repeatForever(autoreverses: true), + value: isAnimating + ) + + Image(systemName: "person.badge.plus") + .font(.system(size: 44, weight: .medium)) + .foregroundColor(Color.appPrimary) + } + + VStack(spacing: 8) { + Text(hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + if !hasFilters { + Text(L10n.Contractors.emptyNoFilters) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + } + + Spacer() + } + .padding(24) + .onAppear { + isAnimating = true + } + } +} + +#Preview { + NavigationView { ContractorsListView() } } diff --git a/iosApp/iosApp/Documents/Components/EmptyStateView.swift b/iosApp/iosApp/Documents/Components/EmptyStateView.swift index c7c4edb..a6a1614 100644 --- a/iosApp/iosApp/Documents/Components/EmptyStateView.swift +++ b/iosApp/iosApp/Documents/Components/EmptyStateView.swift @@ -6,20 +6,26 @@ struct EmptyStateView: View { let message: String var body: some View { - VStack(spacing: AppSpacing.md) { - Image(systemName: icon) - .font(.system(size: 64)) - .foregroundColor(Color.appTextSecondary) + VStack(spacing: OrganicSpacing.cozy) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.08)) + .frame(width: 100, height: 100) + + Image(systemName: icon) + .font(.system(size: 44, weight: .medium)) + .foregroundColor(Color.appPrimary.opacity(0.6)) + } Text(title) - .font(.title3.weight(.semibold)) - .foregroundColor(Color.appTextSecondary) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) Text(message) - .font(.body) - .foregroundColor(Color.appTextSecondary.opacity(0.7)) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) .multilineTextAlignment(.center) } - .padding(AppSpacing.lg) + .padding(OrganicSpacing.comfortable) } } diff --git a/iosApp/iosApp/Documents/DocumentDetailView.swift b/iosApp/iosApp/Documents/DocumentDetailView.swift index f7d2af1..a46f1b4 100644 --- a/iosApp/iosApp/Documents/DocumentDetailView.swift +++ b/iosApp/iosApp/Documents/DocumentDetailView.swift @@ -190,7 +190,7 @@ struct DocumentDetailView: View { @ViewBuilder private func documentDetailContent(document: Document) -> some View { ScrollView { - VStack(spacing: 20) { + VStack(spacing: OrganicSpacing.comfortable) { // Status Badge (for warranties) if document.documentType == "warranty" { warrantyStatusCard(document: document) @@ -212,9 +212,9 @@ struct DocumentDetailView: View { } } .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(radius: 2) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) // Warranty/Item Details if document.documentType == "warranty" { @@ -240,9 +240,9 @@ struct DocumentDetailView: View { } } .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(radius: 2) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) } // Claim Information @@ -262,9 +262,9 @@ struct DocumentDetailView: View { } } .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(radius: 2) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) } // Dates @@ -284,9 +284,9 @@ struct DocumentDetailView: View { } } .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(radius: 2) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) } } @@ -301,16 +301,15 @@ struct DocumentDetailView: View { AuthenticatedImage(mediaURL: image.mediaUrl, contentMode: .fill) .frame(height: 100) .clipped() - .cornerRadius(8) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .onTapGesture { selectedImageIndex = index showImageViewer = true } if index == 5 && document.images.count > 6 { - Rectangle() + RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.black.opacity(0.6)) - .cornerRadius(8) Text("+\(document.images.count - 6)") .foregroundColor(.white) .font(.title2) @@ -321,9 +320,9 @@ struct DocumentDetailView: View { } } .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(radius: 2) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) } // Associations @@ -341,9 +340,9 @@ struct DocumentDetailView: View { } } .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(radius: 2) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) // Additional Information if document.tags != nil || document.notes != nil { @@ -358,9 +357,9 @@ struct DocumentDetailView: View { } } .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(radius: 2) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) } // File Information @@ -381,7 +380,7 @@ struct DocumentDetailView: View { HStack { if isDownloading { ProgressView() - .tint(.white) + .tint(Color.appTextOnPrimary) .scaleEffect(0.8) Text("Downloading...") } else { @@ -392,8 +391,8 @@ struct DocumentDetailView: View { .frame(maxWidth: .infinity) .padding() .background(isDownloading ? Color.appPrimary.opacity(0.7) : Color.appPrimary) - .foregroundColor(.white) - .cornerRadius(8) + .foregroundColor(Color.appTextOnPrimary) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } .disabled(isDownloading) @@ -404,9 +403,9 @@ struct DocumentDetailView: View { } } .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(radius: 2) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) } // Metadata @@ -424,13 +423,13 @@ struct DocumentDetailView: View { } } .padding() - .background(Color(.systemBackground)) - .cornerRadius(12) - .shadow(radius: 2) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) } .padding() } - .background(Color(.systemGroupedBackground)) + .background(WarmGradientBackground()) } @ViewBuilder @@ -442,11 +441,10 @@ struct DocumentDetailView: View { HStack { VStack(alignment: .leading, spacing: 4) { Text(L10n.Documents.status) - .font(.caption) - .foregroundColor(.secondary) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) Text(statusText) - .font(.title2) - .fontWeight(.bold) + .font(.system(size: 20, weight: .bold, design: .rounded)) .foregroundColor(statusColor) } @@ -455,40 +453,40 @@ struct DocumentDetailView: View { if document.isActive && daysUntilExpiration >= 0 { VStack(alignment: .trailing, spacing: 4) { Text(L10n.Documents.daysRemaining) - .font(.caption) - .foregroundColor(.secondary) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) Text("\(daysUntilExpiration)") - .font(.title2) - .fontWeight(.bold) + .font(.system(size: 20, weight: .bold, design: .rounded)) .foregroundColor(statusColor) } } } .padding() - .background(statusColor.opacity(0.1)) - .cornerRadius(12) + .background(statusColor.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } @ViewBuilder private func sectionHeader(_ title: String) -> some View { Text(title) - .font(.headline) - .fontWeight(.bold) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) } @ViewBuilder private func detailRow(label: String, value: String) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label) - .font(.caption) - .foregroundColor(.secondary) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) Text(value) - .font(.body) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextPrimary) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background(Color(.secondarySystemGroupedBackground)) - .cornerRadius(8) + .padding(14) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } private func getStatusColor(isActive: Bool, daysUntilExpiration: Int32) -> Color { diff --git a/iosApp/iosApp/Documents/DocumentFormView.swift b/iosApp/iosApp/Documents/DocumentFormView.swift index 211e079..c2fbc62 100644 --- a/iosApp/iosApp/Documents/DocumentFormView.swift +++ b/iosApp/iosApp/Documents/DocumentFormView.swift @@ -201,7 +201,7 @@ struct DocumentFormView: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) + .background(WarmGradientBackground()) .navigationTitle(isEditMode ? (isWarranty ? L10n.Documents.editWarranty : L10n.Documents.editDocument) : (isWarranty ? L10n.Documents.addWarranty : L10n.Documents.addDocument)) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift index ec059a7..5191d6a 100644 --- a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift +++ b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift @@ -20,15 +20,12 @@ struct DocumentsWarrantiesView: View { let residenceId: Int32? - // Client-side filtering for warranties tab var warranties: [Document] { documentViewModel.documents.filter { doc in guard doc.documentType == "warranty" else { return false } - // Apply active filter if enabled if showActiveOnly && doc.isActive != true { return false } - // Apply category filter if selected if let category = selectedCategory, doc.category != category { return false } @@ -36,11 +33,9 @@ struct DocumentsWarrantiesView: View { } } - // Client-side filtering for documents tab var documents: [Document] { documentViewModel.documents.filter { doc in guard doc.documentType != "warranty" else { return false } - // Apply document type filter if selected if let docType = selectedDocType, doc.documentType != docType { return false } @@ -48,38 +43,31 @@ struct DocumentsWarrantiesView: View { } } - // Check if upgrade screen should be shown (disables add button) private var shouldShowUpgrade: Bool { subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") } var body: some View { ZStack { - Color.appBackgroundPrimary.ignoresSafeArea() + WarmGradientBackground() VStack(spacing: 0) { - // Segmented Control for Tabs - Picker("", selection: $selectedTab) { - Label(L10n.Documents.warranties, systemImage: "checkmark.shield") - .tag(DocumentWarrantyTab.warranties) - Label(L10n.Documents.documents, systemImage: "doc.text") - .tag(DocumentWarrantyTab.documents) - } - .pickerStyle(SegmentedPickerStyle()) - .padding(.horizontal, AppSpacing.md) - .padding(.top, AppSpacing.sm) + // Segmented Control + OrganicSegmentedControl(selection: $selectedTab) + .padding(.horizontal, 16) + .padding(.top, 8) // Search Bar - SearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder) - .padding(.horizontal, AppSpacing.md) - .padding(.top, AppSpacing.xs) + OrganicDocSearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder) + .padding(.horizontal, 16) + .padding(.top, 8) // Active Filters - if selectedCategory != nil || selectedDocType != nil || showActiveOnly { + if selectedCategory != nil || selectedDocType != nil || (selectedTab == .warranties && showActiveOnly) { ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: AppSpacing.xs) { + HStack(spacing: 8) { if selectedTab == .warranties && showActiveOnly { - FilterChip( + OrganicDocFilterChip( title: L10n.Documents.activeOnly, icon: "checkmark.circle.fill", onRemove: { showActiveOnly = false } @@ -87,22 +75,22 @@ struct DocumentsWarrantiesView: View { } if let category = selectedCategory, selectedTab == .warranties { - FilterChip( + OrganicDocFilterChip( title: category, onRemove: { selectedCategory = nil } ) } if let docType = selectedDocType, selectedTab == .documents { - FilterChip( + OrganicDocFilterChip( title: docType, onRemove: { selectedDocType = nil } ) } } - .padding(.horizontal, AppSpacing.md) + .padding(.horizontal, 16) } - .padding(.vertical, AppSpacing.xs) + .padding(.vertical, 8) } // Content @@ -119,22 +107,22 @@ struct DocumentsWarrantiesView: View { } } } - .navigationTitle(L10n.Documents.documentsAndWarranties) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: AppSpacing.sm) { - // Active Filter (for warranties) - client-side, no API call + HStack(spacing: 12) { + // Active Filter (for warranties) if selectedTab == .warranties { Button(action: { showActiveOnly.toggle() }) { Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle") + .font(.system(size: 16, weight: .medium)) .foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary) } } - // Filter Menu - client-side filtering, no API calls + // Filter Menu Menu { if selectedTab == .warranties { Button(action: { @@ -171,35 +159,29 @@ struct DocumentsWarrantiesView: View { } } label: { Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 16, weight: .medium)) .foregroundColor((selectedCategory != nil || selectedDocType != nil) ? Color.appPrimary : Color.appTextSecondary) } - // Add Button (disabled when showing upgrade screen) + // Add Button Button(action: { - // Check LIVE document count before adding let currentCount = documentViewModel.documents.count if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "documents") { - // Track paywall shown PostHogAnalytics.shared.capture(AnalyticsEvents.documentsPaywallShown, properties: ["current_count": currentCount]) showingUpgradePrompt = true } else { showAddSheet = true } }) { - Image(systemName: "plus.circle.fill") - .font(.title2) - .foregroundColor(Color.appPrimary) + OrganicDocToolbarButton() } } } } .onAppear { - // Track screen view PostHogAnalytics.shared.screen(AnalyticsEvents.documentsScreenShown) - // Load all documents once - filtering is client-side loadAllDocuments() } - // No need for onChange on selectedTab - filtering is client-side .sheet(isPresented: $showAddSheet) { AddDocumentView( residenceId: residenceId, @@ -214,23 +196,151 @@ struct DocumentsWarrantiesView: View { } private func loadAllDocuments(forceRefresh: Bool = false) { - // Load all documents without filters to use cache - // Filtering is done client-side in the computed properties documentViewModel.loadDocuments(forceRefresh: forceRefresh) } private func loadWarranties() { - // Just reload all - filtering happens client-side loadAllDocuments() } private func loadDocuments() { - // Just reload all - filtering happens client-side loadAllDocuments() } } +// MARK: - Organic Segmented Control + +private struct OrganicSegmentedControl: View { + @Binding var selection: DocumentWarrantyTab + + var body: some View { + HStack(spacing: 0) { + OrganicSegmentButton( + title: L10n.Documents.warranties, + icon: "checkmark.shield", + isSelected: selection == .warranties, + action: { selection = .warranties } + ) + + OrganicSegmentButton( + title: L10n.Documents.documents, + icon: "doc.text", + isSelected: selection == .documents, + action: { selection = .documents } + ) + } + .padding(4) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .naturalShadow(.subtle) + } +} + +private struct OrganicSegmentButton: View { + let title: String + let icon: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 13, weight: .semibold)) + Text(title) + .font(.system(size: 14, weight: .semibold)) + } + .foregroundColor(isSelected ? Color.appTextOnPrimary : Color.appTextSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(isSelected ? Color.appPrimary : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } +} + +// MARK: - Organic Doc Search Bar + +private struct OrganicDocSearchBar: View { + @Binding var text: String + var placeholder: String + + var body: some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 32, height: 32) + Image(systemName: "magnifyingglass") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appPrimary) + } + + TextField(placeholder, text: $text) + .font(.system(size: 16, weight: .medium)) + + if !text.isEmpty { + Button(action: { text = "" }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18)) + .foregroundColor(Color.appTextSecondary) + } + } + } + .padding(14) + .background(Color.appBackgroundSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) + } +} + +// MARK: - Organic Doc Filter Chip + +private struct OrganicDocFilterChip: View { + let title: String + var icon: String? = nil + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 6) { + if let icon = icon { + Image(systemName: icon) + .font(.system(size: 12, weight: .semibold)) + } + Text(title) + .font(.system(size: 13, weight: .semibold)) + + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.appPrimary.opacity(0.15)) + .foregroundColor(Color.appPrimary) + .clipShape(Capsule()) + } +} + +// MARK: - Organic Doc Toolbar Button + +private struct OrganicDocToolbarButton: View { + var body: some View { + ZStack { + Circle() + .fill(Color.appPrimary) + .frame(width: 32, height: 32) + + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(Color.appTextOnPrimary) + } + } +} + // MARK: - Supporting Types + extension DocumentCategory: CaseIterable { public static var allCases: [DocumentCategory] { return [.appliance, .hvac, .plumbing, .electrical, .roofing, .structural, .other] diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index d5d095e..4a28bb9 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -14,7 +14,7 @@ struct MainTabView: View { } .id(refreshID) .tabItem { - Label("Residences", image: "tab_view_house") + Label("Home", image: "tab_view_house") } .tag(0) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab) @@ -24,7 +24,7 @@ struct MainTabView: View { } .id(refreshID) .tabItem { - Label("Tasks", systemImage: "checkmark.circle.fill") + Label("Tasks", systemImage: "checklist") } .tag(1) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab) @@ -34,7 +34,7 @@ struct MainTabView: View { } .id(refreshID) .tabItem { - Label("Contractors", systemImage: "wrench.and.screwdriver.fill") + Label("Pros", systemImage: "wrench.and.screwdriver.fill") } .tag(2) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab) @@ -44,7 +44,7 @@ struct MainTabView: View { } .id(refreshID) .tabItem { - Label("Documents", systemImage: "doc.text.fill") + Label("Docs", systemImage: "doc.text.fill") } .tag(3) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab) @@ -53,23 +53,44 @@ struct MainTabView: View { .onChange(of: authManager.isAuthenticated) { _ in selectedTab = 0 } - // Check for pending navigation when view appears (app launched from notification) .onAppear { + // Configure tab bar appearance + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + + // Use theme-aware colors + appearance.backgroundColor = UIColor(Color.appBackgroundSecondary) + + // Selected item + appearance.stackedLayoutAppearance.selected.iconColor = UIColor(Color.appPrimary) + appearance.stackedLayoutAppearance.selected.titleTextAttributes = [ + .foregroundColor: UIColor(Color.appPrimary), + .font: UIFont.systemFont(ofSize: 10, weight: .semibold) + ] + + // Normal item + appearance.stackedLayoutAppearance.normal.iconColor = UIColor(Color.appTextSecondary) + appearance.stackedLayoutAppearance.normal.titleTextAttributes = [ + .foregroundColor: UIColor(Color.appTextSecondary), + .font: UIFont.systemFont(ofSize: 10, weight: .medium) + ] + + UITabBar.appearance().standardAppearance = appearance + UITabBar.appearance().scrollEdgeAppearance = appearance + + // Handle pending navigation from push notification if pushManager.pendingNavigationTaskId != nil { - selectedTab = 1 // Switch to Tasks tab - // Note: Don't clear here - AllTasksView will handle navigation and clear it + selectedTab = 1 } } - // Handle push notification deep links - switch to appropriate tab - // The actual task navigation is handled by AllTasksView .onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in - selectedTab = 1 // Switch to Tasks tab + selectedTab = 1 } .onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in - selectedTab = 1 // Switch to Tasks tab + selectedTab = 1 } .onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in - selectedTab = 0 // Switch to Residences tab + selectedTab = 0 } } } diff --git a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift index 5968dd9..7ae9b6b 100644 --- a/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingCreateAccountView.swift @@ -10,7 +10,9 @@ struct OnboardingCreateAccountContent: View { @StateObject private var appleSignInViewModel = AppleSignInViewModel() @State private var showingLoginSheet = false @State private var isExpanded = false + @State private var isAnimating = false @FocusState private var focusedField: Field? + @Environment(\.colorScheme) var colorScheme enum Field { case username, email, password, confirmPassword @@ -24,35 +26,87 @@ struct OnboardingCreateAccountContent: View { } var body: some View { - ScrollView { - VStack(spacing: AppSpacing.xl) { - // Header - VStack(spacing: AppSpacing.sm) { - ZStack { - Circle() - .fill(Color.appPrimary.opacity(0.1)) - .frame(width: 80, height: 80) + ZStack { + WarmGradientBackground() - Image(systemName: "person.badge.plus") - .font(.system(size: 36)) - .foregroundStyle(Color.appPrimary.gradient) + // Decorative blobs + GeometryReader { geo in + OrganicBlobShape(variation: 1) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.06), + Color.appPrimary.opacity(0.01), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.3 + ) + ) + .frame(width: geo.size.width * 0.5, height: geo.size.height * 0.25) + .offset(x: geo.size.width * 0.55, y: geo.size.height * 0.05) + .blur(radius: 20) + } + + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.comfortable) { + // Header + VStack(spacing: 16) { + ZStack { + // Pulsing glow + 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) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true), + value: isAnimating + ) + + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 80, height: 80) + + Image(systemName: "person.badge.plus") + .font(.system(size: 36, weight: .medium)) + .foregroundColor(.white) + } + .naturalShadow(.pronounced) } Text("Save your home to your account") - .font(.title2) - .fontWeight(.bold) + .font(.system(size: 24, weight: .bold, design: .rounded)) .foregroundColor(Color.appTextPrimary) .multilineTextAlignment(.center) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle) Text("Your data will be synced across devices") - .font(.subheadline) + .font(.system(size: 15, weight: .medium)) .foregroundColor(Color.appTextSecondary) } - .padding(.top, AppSpacing.lg) + .padding(.top, OrganicSpacing.comfortable) // Sign in with Apple (Primary) - VStack(spacing: AppSpacing.md) { + VStack(spacing: 14) { SignInWithAppleButton( onRequest: { request in request.requestedScopes = [.fullName, .email] @@ -60,7 +114,7 @@ struct OnboardingCreateAccountContent: View { onCompletion: { _ in } ) .frame(height: 56) - .cornerRadius(AppRadius.md) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .signInWithAppleButtonStyle(.black) .disabled(appleSignInViewModel.isLoading) .opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0) @@ -73,122 +127,146 @@ struct OnboardingCreateAccountContent: View { } if appleSignInViewModel.isLoading { - HStack { + HStack(spacing: 10) { ProgressView() + .tint(Color.appPrimary) Text("Signing in with Apple...") - .font(.subheadline) + .font(.system(size: 15, weight: .medium)) .foregroundColor(Color.appTextSecondary) } } if let error = appleSignInViewModel.errorMessage { - errorMessage(error) + OrganicErrorMessage(message: error) } } // Divider - HStack { - Rectangle() - .fill(Color.appTextSecondary.opacity(0.3)) - .frame(height: 1) - Text("or") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - .padding(.horizontal, AppSpacing.sm) - Rectangle() - .fill(Color.appTextSecondary.opacity(0.3)) - .frame(height: 1) - } + OrganicDividerWithText(text: "or") // Create Account Form - VStack(spacing: AppSpacing.md) { + VStack(spacing: 14) { if !isExpanded { // Collapsed state Button(action: { - withAnimation(.easeInOut(duration: 0.3)) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { isExpanded = true } }) { - HStack(spacing: AppSpacing.sm) { - Image(systemName: "envelope.fill") - .font(.title3) + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.15)) + .frame(width: 36, height: 36) + + Image(systemName: "envelope.fill") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(Color.appPrimary) + } + Text("Create Account with Email") - .font(.headline) - .fontWeight(.medium) + .font(.system(size: 17, weight: .semibold)) } .frame(maxWidth: .infinity) .frame(height: 56) .foregroundColor(Color.appPrimary) .background(Color.appPrimary.opacity(0.1)) - .cornerRadius(AppRadius.md) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.appPrimary.opacity(0.2), lineWidth: 1) + ) } .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.emailSignUpExpandButton) } else { // Expanded form - VStack(spacing: AppSpacing.md) { - // Username - formField( - icon: "person.fill", - placeholder: "Username", - text: $viewModel.username, - field: .username, - keyboardType: .default, - contentType: .username - ) + VStack(spacing: 14) { + // Form card + VStack(spacing: 16) { + OrganicOnboardingTextField( + icon: "person.fill", + placeholder: "Username", + text: $viewModel.username, + isFocused: focusedField == .username + ) + .focused($focusedField, equals: .username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textContentType(.username) - // Email - formField( - icon: "envelope.fill", - placeholder: "Email", - text: $viewModel.email, - field: .email, - keyboardType: .emailAddress, - contentType: .emailAddress - ) + OrganicOnboardingTextField( + icon: "envelope.fill", + placeholder: "Email", + text: $viewModel.email, + isFocused: focusedField == .email + ) + .focused($focusedField, equals: .email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + .textContentType(.emailAddress) - // Password - secureFormField( - icon: "lock.fill", - placeholder: "Password", - text: $viewModel.password, - field: .password - ) + OrganicOnboardingSecureField( + icon: "lock.fill", + placeholder: "Password", + text: $viewModel.password, + isFocused: focusedField == .password + ) + .focused($focusedField, equals: .password) - // Confirm Password - secureFormField( - icon: "lock.fill", - placeholder: "Confirm Password", - text: $viewModel.confirmPassword, - field: .confirmPassword + OrganicOnboardingSecureField( + icon: "lock.fill", + placeholder: "Confirm Password", + text: $viewModel.confirmPassword, + isFocused: focusedField == .confirmPassword + ) + .focused($focusedField, equals: .confirmPassword) + } + .padding(OrganicSpacing.cozy) + .background( + ZStack { + Color.appBackgroundSecondary + + GeometryReader { geo in + OrganicBlobShape(variation: 2) + .fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.04 : 0.02)) + .frame(width: geo.size.width * 0.4, height: geo.size.height * 0.5) + .offset(x: geo.size.width * 0.65, y: geo.size.height * 0.5) + .blur(radius: 15) + } + + GrainTexture(opacity: 0.015) + } ) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .naturalShadow(.medium) if let error = viewModel.errorMessage { - errorMessage(error) + OrganicErrorMessage(message: error) } // Register button Button(action: { viewModel.register() }) { - HStack { + HStack(spacing: 10) { if viewModel.isLoading { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) } Text(viewModel.isLoading ? "Creating Account..." : "Create Account") - .font(.headline) - .fontWeight(.semibold) + .font(.system(size: 17, weight: .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) + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing)) + : AnyShapeStyle(Color.appTextSecondary.opacity(0.4)) ) - .cornerRadius(AppRadius.md) - .shadow(color: isFormValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(isFormValid ? .medium : .subtle) } .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountButton) .disabled(!isFormValid || viewModel.isLoading) @@ -197,25 +275,24 @@ struct OnboardingCreateAccountContent: View { } } - // Already have an account - HStack(spacing: AppSpacing.xs) { - Text("Already have an account?") - .font(.body) - .foregroundColor(Color.appTextSecondary) + // Already have an account + HStack(spacing: 6) { + Text("Already have an account?") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) - Button("Log in") { - showingLoginSheet = true + Button("Log in") { + showingLoginSheet = true + } + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color.appPrimary) } - .font(.body) - .fontWeight(.semibold) - .foregroundColor(Color.appPrimary) + .padding(.top, 8) } - .padding(.top, AppSpacing.md) + .padding(.horizontal, OrganicSpacing.comfortable) + .padding(.bottom, OrganicSpacing.airy) } - .padding(.horizontal, AppSpacing.xl) - .padding(.bottom, AppSpacing.xxxl) } - .background(Color.appBackgroundPrimary) .sheet(isPresented: $showingLoginSheet) { LoginView(onLoginSuccess: { showingLoginSheet = false @@ -229,6 +306,7 @@ struct OnboardingCreateAccountContent: View { } } .onAppear { + isAnimating = true // Set up Apple Sign In callback appleSignInViewModel.onSignInSuccess = { isVerified in AuthenticationManager.shared.login(verified: isVerified) @@ -237,74 +315,139 @@ struct OnboardingCreateAccountContent: View { } } } +} - // MARK: - Form Fields +// MARK: - Organic Onboarding TextField - private func formField( - icon: String, - placeholder: String, - text: Binding, - field: Field, - keyboardType: UIKeyboardType, - contentType: UITextContentType - ) -> some View { - HStack(spacing: AppSpacing.sm) { - Image(systemName: icon) - .foregroundColor(Color.appTextSecondary) - .frame(width: 20) +private struct OrganicOnboardingTextField: View { + let icon: String + let placeholder: String + @Binding var text: String + var isFocused: Bool = false - TextField(placeholder, text: text) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(keyboardType) - .textContentType(contentType) - .focused($focusedField, equals: field) + var body: some View { + HStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 36, height: 36) + + Image(systemName: icon) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appPrimary) + } + + TextField(placeholder, text: $text) + .font(.system(size: 16, weight: .medium)) } - .padding(AppSpacing.md) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.md) + .padding(14) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: AppRadius.md) - .stroke(focusedField == field ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 1.5) ) } +} - private func secureFormField( - icon: String, - placeholder: String, - text: Binding, - field: Field - ) -> some View { - HStack(spacing: AppSpacing.sm) { - Image(systemName: icon) - .foregroundColor(Color.appTextSecondary) - .frame(width: 20) +// MARK: - Organic Onboarding Secure Field - SecureField(placeholder, text: text) - .textContentType(.password) - .focused($focusedField, equals: field) +private struct OrganicOnboardingSecureField: View { + let icon: String + let placeholder: String + @Binding var text: String + var isFocused: Bool = false + @State private var showPassword = false + + var body: some View { + HStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 36, height: 36) + + Image(systemName: icon) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appPrimary) + } + + if showPassword { + TextField(placeholder, text: $text) + .font(.system(size: 16, weight: .medium)) + .textContentType(.password) + } else { + SecureField(placeholder, text: $text) + .font(.system(size: 16, weight: .medium)) + .textContentType(.password) + } + + Button(action: { showPassword.toggle() }) { + Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } } - .padding(AppSpacing.md) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.md) + .padding(14) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: AppRadius.md) - .stroke(focusedField == field ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 1.5) ) } +} - private func errorMessage(_ message: String) -> some View { - HStack(spacing: AppSpacing.sm) { +// MARK: - Organic Error Message + +private struct OrganicErrorMessage: View { + let message: String + + var body: some View { + HStack(spacing: 10) { Image(systemName: "exclamationmark.circle.fill") .foregroundColor(Color.appError) Text(message) - .font(.callout) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appError) Spacer() } - .padding(AppSpacing.md) + .padding(14) .background(Color.appError.opacity(0.1)) - .cornerRadius(AppRadius.md) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +// MARK: - Organic Divider with Text + +private struct OrganicDividerWithText: View { + let text: String + + var body: some View { + HStack(spacing: 16) { + Rectangle() + .fill( + LinearGradient( + colors: [Color.clear, Color.appTextSecondary.opacity(0.25)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(height: 1) + + Text(text) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + + Rectangle() + .fill( + LinearGradient( + colors: [Color.appTextSecondary.opacity(0.25), Color.clear], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(height: 1) + } } } @@ -315,32 +458,41 @@ struct OnboardingCreateAccountView: View { var onBack: () -> Void var body: some View { - VStack(spacing: 0) { - // Navigation bar - HStack { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.title2) - .foregroundColor(Color.appPrimary) + ZStack { + WarmGradientBackground() + + VStack(spacing: 0) { + // Navigation bar + HStack { + Button(action: onBack) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 36, height: 36) + + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + } + + Spacer() + + OnboardingProgressIndicator(currentStep: 3, totalSteps: 5) + + Spacer() + + // Invisible spacer for alignment + Circle() + .fill(Color.clear) + .frame(width: 36, height: 36) } + .padding(.horizontal, 20) + .padding(.vertical, 12) - Spacer() - - OnboardingProgressIndicator(currentStep: 3, totalSteps: 5) - - Spacer() - - // Invisible spacer for alignment - Image(systemName: "chevron.left") - .font(.title2) - .opacity(0) + OnboardingCreateAccountContent(onAccountCreated: onAccountCreated) } - .padding(.horizontal, AppSpacing.lg) - .padding(.vertical, AppSpacing.md) - - OnboardingCreateAccountContent(onAccountCreated: onAccountCreated) } - .background(Color.appBackgroundPrimary) } } diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift index 8a0979e..b5aaa3d 100644 --- a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift @@ -13,6 +13,8 @@ struct OnboardingFirstTaskContent: View { @State private var isCreatingTasks = false @State private var showCustomTaskSheet = false @State private var expandedCategory: String? = nil + @State private var isAnimating = false + @Environment(\.colorScheme) var colorScheme /// Maximum tasks allowed for free tier (matches API TierLimits) private let maxTasksAllowed = 5 @@ -99,196 +101,243 @@ struct OnboardingFirstTaskContent: View { } var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(spacing: AppSpacing.xl) { - // Header with celebration - VStack(spacing: AppSpacing.md) { - ZStack { - // Celebration circles - Circle() - .fill( - RadialGradient( - colors: [Color.appPrimary.opacity(0.2), Color.clear], - center: .center, - startRadius: 30, - endRadius: 80 - ) - ) - .frame(width: 140, height: 140) - .offset(x: -15, y: -15) + ZStack { + WarmGradientBackground() - Circle() - .fill( - RadialGradient( - colors: [Color.appAccent.opacity(0.2), Color.clear], - center: .center, - startRadius: 30, - endRadius: 80 - ) - ) - .frame(width: 140, height: 140) - .offset(x: 15, y: 15) + // Decorative blobs + GeometryReader { geo in + OrganicBlobShape(variation: 1) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.06), + Color.appPrimary.opacity(0.01), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.3 + ) + ) + .frame(width: geo.size.width * 0.5, height: geo.size.height * 0.25) + .offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.1) + .blur(radius: 20) - // Party icon + OrganicBlobShape(variation: 2) + .fill( + RadialGradient( + colors: [ + Color.appAccent.opacity(0.05), + Color.appAccent.opacity(0.01), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.25 + ) + ) + .frame(width: geo.size.width * 0.4, height: geo.size.height * 0.2) + .offset(x: geo.size.width * 0.65, y: geo.size.height * 0.75) + .blur(radius: 15) + } + + VStack(spacing: 0) { + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.comfortable) { + // Header with celebration + VStack(spacing: 16) { ZStack { + // Celebration circles Circle() .fill( - LinearGradient( - colors: [Color.appPrimary, Color.appSecondary], - startPoint: .topLeading, - endPoint: .bottomTrailing + RadialGradient( + colors: [Color.appPrimary.opacity(0.15), Color.clear], + center: .center, + startRadius: 30, + endRadius: 80 ) ) - .frame(width: 80, height: 80) + .frame(width: 140, height: 140) + .offset(x: -15, y: -15) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true), + value: isAnimating + ) - Image(systemName: "party.popper.fill") - .font(.system(size: 36)) - .foregroundColor(.white) + Circle() + .fill( + RadialGradient( + colors: [Color.appAccent.opacity(0.15), Color.clear], + center: .center, + startRadius: 30, + endRadius: 80 + ) + ) + .frame(width: 140, height: 140) + .offset(x: 15, y: 15) + .scaleEffect(isAnimating ? 0.95 : 1.05) + .animation( + Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5), + value: isAnimating + ) + + // Party icon + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appPrimary, Color.appSecondary], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 80, height: 80) + + Image(systemName: "party.popper.fill") + .font(.system(size: 36)) + .foregroundColor(.white) + } + .naturalShadow(.pronounced) } - .shadow(color: Color.appPrimary.opacity(0.4), radius: 15, y: 8) + + Text("You're all set up!") + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) } + .padding(.top, OrganicSpacing.comfortable) - Text("You're all set up!") - .font(.title) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) + // Selection counter chip + HStack(spacing: 8) { + Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill") + .foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary) - Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - .lineSpacing(4) - } - .padding(.top, AppSpacing.lg) + Text("\(selectedCount)/\(maxTasksAllowed) tasks selected") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary) + } + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1)) + .clipShape(Capsule()) + .animation(.spring(response: 0.3), value: selectedCount) - // Selection counter chip - HStack(spacing: AppSpacing.sm) { - Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill") - .foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary) - - Text("\(selectedCount)/\(maxTasksAllowed) tasks selected") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary) - } - .padding(.horizontal, AppSpacing.lg) - .padding(.vertical, AppSpacing.sm) - .background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1)) - .cornerRadius(AppRadius.xl) - .animation(.spring(response: 0.3), value: selectedCount) - - // Task categories - VStack(spacing: AppSpacing.md) { - ForEach(taskCategories) { category in - TaskCategorySection( - category: category, - selectedTasks: $selectedTasks, - isExpanded: expandedCategory == category.name, - isAtMaxSelection: isAtMaxSelection, - onToggleExpand: { - withAnimation(.spring(response: 0.3)) { - if expandedCategory == category.name { - expandedCategory = nil - } else { - expandedCategory = category.name + // Task categories + VStack(spacing: 12) { + ForEach(taskCategories) { category in + OrganicTaskCategorySection( + category: category, + selectedTasks: $selectedTasks, + isExpanded: expandedCategory == category.name, + isAtMaxSelection: isAtMaxSelection, + onToggleExpand: { + withAnimation(.spring(response: 0.3)) { + if expandedCategory == category.name { + expandedCategory = nil + } else { + expandedCategory = category.name + } } } - } + ) + } + } + .padding(.horizontal, OrganicSpacing.comfortable) + + // Quick add all popular + Button(action: selectPopularTasks) { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .font(.system(size: 16, weight: .semibold)) + + Text("Add Most Popular") + .font(.system(size: 16, weight: .semibold)) + } + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary, Color.appAccent], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(maxWidth: .infinity) + .frame(height: 56) + .background( + LinearGradient( + colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke( + LinearGradient( + colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)], + startPoint: .leading, + endPoint: .trailing + ), + lineWidth: 1.5 + ) ) } + .padding(.horizontal, OrganicSpacing.comfortable) } - .padding(.horizontal, AppSpacing.lg) + .padding(.bottom, 140) // Space for button + } - // Quick add all popular - Button(action: selectPopularTasks) { - HStack(spacing: AppSpacing.sm) { - Image(systemName: "sparkles") - .font(.headline) + // Bottom action area + VStack(spacing: 14) { + Button(action: addSelectedTasks) { + HStack(spacing: 10) { + if isCreatingTasks { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now") + .font(.system(size: 17, weight: .bold)) - Text("Add Most Popular") - .font(.headline) - .fontWeight(.medium) + Image(systemName: "arrow.right") + .font(.system(size: 16, weight: .bold)) + } } - .foregroundStyle( - LinearGradient( - colors: [Color.appPrimary, Color.appAccent], - startPoint: .leading, - endPoint: .trailing - ) - ) .frame(maxWidth: .infinity) .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) .background( - LinearGradient( - colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)], - startPoint: .leading, - endPoint: .trailing - ) - ) - .cornerRadius(AppRadius.lg) - .overlay( - RoundedRectangle(cornerRadius: AppRadius.lg) - .stroke( - LinearGradient( - colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)], - startPoint: .leading, - endPoint: .trailing - ), - lineWidth: 1.5 - ) + selectedCount > 0 + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing)) + : AnyShapeStyle(Color.appTextSecondary.opacity(0.5)) ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(selectedCount > 0 ? .medium : .subtle) } - .padding(.horizontal, AppSpacing.lg) + .disabled(isCreatingTasks) + .animation(.easeInOut(duration: 0.2), value: selectedCount) } - .padding(.bottom, 140) // Space for button - } - - // Bottom action area - VStack(spacing: AppSpacing.md) { - Button(action: addSelectedTasks) { - HStack(spacing: AppSpacing.sm) { - if isCreatingTasks { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } else { - Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now") - .font(.headline) - .fontWeight(.bold) - - Image(systemName: "arrow.right") - .font(.headline) - } - } - .frame(maxWidth: .infinity) - .frame(height: 56) - .foregroundColor(Color.appTextOnPrimary) - .background( - selectedCount > 0 - ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing)) - : AnyShapeStyle(Color.appTextSecondary.opacity(0.5)) + .padding(.horizontal, OrganicSpacing.comfortable) + .padding(.bottom, OrganicSpacing.airy) + .background( + LinearGradient( + colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary], + startPoint: .top, + endPoint: .center ) - .cornerRadius(AppRadius.lg) - .shadow(color: selectedCount > 0 ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8) - } - .disabled(isCreatingTasks) - .animation(.easeInOut(duration: 0.2), value: selectedCount) - } - .padding(.horizontal, AppSpacing.xl) - .padding(.bottom, AppSpacing.xxxl) - .background( - LinearGradient( - colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary], - startPoint: .top, - endPoint: .center + .frame(height: 60) + .offset(y: -60) + , alignment: .top ) - .frame(height: 60) - .offset(y: -60) - , alignment: .top - ) + } } - .background(Color.appBackgroundPrimary) .onAppear { + isAnimating = true // Expand first category by default expandedCategory = taskCategories.first?.name } @@ -393,15 +442,17 @@ struct OnboardingTaskCategory: Identifiable { let tasks: [OnboardingTaskTemplate] } -// MARK: - Task Category Section +// MARK: - Organic Task Category Section -struct TaskCategorySection: View { +private struct OrganicTaskCategorySection: View { let category: OnboardingTaskCategory @Binding var selectedTasks: Set let isExpanded: Bool let isAtMaxSelection: Bool var onToggleExpand: () -> Void + @Environment(\.colorScheme) var colorScheme + private var selectedInCategory: Int { category.tasks.filter { selectedTasks.contains($0.id) }.count } @@ -410,7 +461,7 @@ struct TaskCategorySection: View { VStack(spacing: 0) { // Category header Button(action: onToggleExpand) { - HStack(spacing: AppSpacing.md) { + HStack(spacing: 14) { // Category icon ZStack { Circle() @@ -424,14 +475,14 @@ struct TaskCategorySection: View { .frame(width: 44, height: 44) Image(systemName: category.icon) - .font(.title3) + .font(.system(size: 18, weight: .medium)) .foregroundColor(.white) } + .naturalShadow(.subtle) // Category name Text(category.name) - .font(.headline) - .fontWeight(.semibold) + .font(.system(size: 16, weight: .semibold)) .foregroundColor(Color.appTextPrimary) Spacer() @@ -439,8 +490,7 @@ struct TaskCategorySection: View { // Selection badge if selectedInCategory > 0 { Text("\(selectedInCategory)") - .font(.caption) - .fontWeight(.bold) + .font(.system(size: 12, weight: .bold)) .foregroundColor(.white) .frame(width: 24, height: 24) .background(category.color) @@ -449,13 +499,26 @@ struct TaskCategorySection: View { // Chevron Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption) - .fontWeight(.semibold) + .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color.appTextSecondary) } - .padding(AppSpacing.md) - .background(Color.appBackgroundSecondary) - .cornerRadius(isExpanded ? AppRadius.lg : AppRadius.lg, corners: isExpanded ? [.topLeft, .topRight] : .allCorners) + .padding(14) + .background( + ZStack { + Color.appBackgroundSecondary + GrainTexture(opacity: 0.01) + } + ) + .clipShape(RoundedRectangle(cornerRadius: isExpanded ? 18 : 18, style: .continuous)) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 18, + bottomLeadingRadius: isExpanded ? 0 : 18, + bottomTrailingRadius: isExpanded ? 0 : 18, + topTrailingRadius: 18, + style: .continuous + ) + ) } .buttonStyle(.plain) @@ -464,7 +527,7 @@ struct TaskCategorySection: View { VStack(spacing: 0) { ForEach(category.tasks) { task in let taskIsSelected = selectedTasks.contains(task.id) - OnboardingTaskTemplateRow( + OrganicTaskTemplateRow( template: task, isSelected: taskIsSelected, isDisabled: isAtMaxSelection && !taskIsSelected, @@ -486,16 +549,24 @@ struct TaskCategorySection: View { } } .background(Color.appBackgroundSecondary.opacity(0.5)) - .cornerRadius(AppRadius.lg, corners: [.bottomLeft, .bottomRight]) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 0, + bottomLeadingRadius: 18, + bottomTrailingRadius: 18, + topTrailingRadius: 0, + style: .continuous + ) + ) } } - .shadow(color: Color.black.opacity(0.05), radius: 8, y: 4) + .naturalShadow(.subtle) } } -// MARK: - Task Template Row +// MARK: - Organic Task Template Row -struct OnboardingTaskTemplateRow: View { +private struct OrganicTaskTemplateRow: View { let template: OnboardingTaskTemplate let isSelected: Bool let isDisabled: Bool @@ -503,7 +574,7 @@ struct OnboardingTaskTemplateRow: View { var body: some View { Button(action: onTap) { - HStack(spacing: AppSpacing.md) { + HStack(spacing: 14) { // Checkbox ZStack { Circle() @@ -516,8 +587,7 @@ struct OnboardingTaskTemplateRow: View { .frame(width: 28, height: 28) Image(systemName: "checkmark") - .font(.caption) - .fontWeight(.bold) + .font(.system(size: 12, weight: .bold)) .foregroundColor(.white) } } @@ -525,12 +595,11 @@ struct OnboardingTaskTemplateRow: View { // Task info VStack(alignment: .leading, spacing: 2) { Text(template.title) - .font(.subheadline) - .fontWeight(.medium) + .font(.system(size: 15, weight: .medium)) .foregroundColor(isDisabled ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary) Text(template.frequency.capitalized) - .font(.caption) + .font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary.opacity(isDisabled ? 0.5 : 1)) } @@ -538,11 +607,11 @@ struct OnboardingTaskTemplateRow: View { // Task icon Image(systemName: template.icon) - .font(.title3) + .font(.system(size: 18, weight: .medium)) .foregroundColor(template.color.opacity(isDisabled ? 0.3 : 0.6)) } - .padding(.horizontal, AppSpacing.md) - .padding(.vertical, AppSpacing.sm) + .padding(.horizontal, 14) + .padding(.vertical, 12) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -569,27 +638,29 @@ struct OnboardingFirstTaskView: View { var onSkip: () -> Void var body: some View { - VStack(spacing: 0) { - // Navigation bar - HStack { - Spacer() + ZStack { + WarmGradientBackground() - Button(action: onSkip) { - Text("Skip") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(Color.appTextSecondary) + VStack(spacing: 0) { + // Navigation bar + HStack { + Spacer() + + Button(action: onSkip) { + Text("Skip") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } } - } - .padding(.horizontal, AppSpacing.lg) - .padding(.vertical, AppSpacing.md) + .padding(.horizontal, 20) + .padding(.vertical, 12) - OnboardingFirstTaskContent( - residenceName: residenceName, - onTaskAdded: onTaskAdded - ) + OnboardingFirstTaskContent( + residenceName: residenceName, + onTaskAdded: onTaskAdded + ) + } } - .background(Color.appBackgroundPrimary) } } diff --git a/iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift b/iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift index 62db2cf..2cc0a55 100644 --- a/iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingJoinResidenceView.swift @@ -9,123 +9,215 @@ struct OnboardingJoinResidenceContent: View { @State private var shareCode: String = "" @State private var isLoading = false @State private var errorMessage: String? + @State private var isAnimating = false @FocusState private var isCodeFieldFocused: Bool + @Environment(\.colorScheme) var colorScheme private var isCodeValid: Bool { shareCode.count == 6 } var body: some View { - VStack(spacing: 0) { - Spacer() + ZStack { + WarmGradientBackground() - // Content - VStack(spacing: AppSpacing.xl) { - // Icon - ZStack { - Circle() - .fill(Color.appPrimary.opacity(0.1)) - .frame(width: 100, height: 100) - - Image(systemName: "person.2.badge.key.fill") - .font(.system(size: 44)) - .foregroundStyle(Color.appPrimary.gradient) - } - - // Title - VStack(spacing: AppSpacing.sm) { - Text("Join a Residence") - .font(.title2) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) - - Text("Enter the 6-character code shared with you to join an existing home.") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - } - - // Code input - VStack(alignment: .leading, spacing: AppSpacing.xs) { - HStack(spacing: AppSpacing.sm) { - Image(systemName: "key.fill") - .foregroundColor(Color.appTextSecondary) - .frame(width: 20) - - TextField("Enter share code", text: $shareCode) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .focused($isCodeFieldFocused) - .onChange(of: shareCode) { _, newValue in - // Limit to 6 characters - if newValue.count > 6 { - shareCode = String(newValue.prefix(6)) - } - // Clear error when typing - errorMessage = nil - } - } - .padding(AppSpacing.md) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.md) - .overlay( - RoundedRectangle(cornerRadius: AppRadius.md) - .stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + // Decorative blobs + GeometryReader { geo in + OrganicBlobShape(variation: 1) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.07), + Color.appPrimary.opacity(0.02), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.35 + ) ) - } - .padding(.horizontal, AppSpacing.xl) + .frame(width: geo.size.width * 0.6, height: geo.size.height * 0.35) + .offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.1) + .blur(radius: 25) - // Error message - if let error = errorMessage { - HStack(spacing: AppSpacing.sm) { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(Color.appError) - Text(error) - .font(.callout) - .foregroundColor(Color.appError) - Spacer() - } - .padding(AppSpacing.md) - .background(Color.appError.opacity(0.1)) - .cornerRadius(AppRadius.md) - .padding(.horizontal, AppSpacing.xl) - } - - // Loading indicator - if isLoading { - HStack { - ProgressView() - Text("Joining residence...") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - } - } + OrganicBlobShape(variation: 2) + .fill( + RadialGradient( + colors: [ + Color.appAccent.opacity(0.05), + Color.appAccent.opacity(0.01), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.25 + ) + ) + .frame(width: geo.size.width * 0.4, height: geo.size.height * 0.25) + .offset(x: geo.size.width * 0.65, y: geo.size.height * 0.6) + .blur(radius: 20) } - Spacer() + VStack(spacing: 0) { + Spacer() - // Join button - Button(action: joinResidence) { - Text("Join Residence") - .font(.headline) - .fontWeight(.semibold) + // Content + VStack(spacing: OrganicSpacing.comfortable) { + // Icon with pulsing glow + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.15), + Color.appPrimary.opacity(0.05), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 70 + ) + ) + .frame(width: 140, height: 140) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true), + value: isAnimating + ) + + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 90, height: 90) + + Image(systemName: "person.2.badge.key.fill") + .font(.system(size: 40, weight: .medium)) + .foregroundColor(.white) + } + .naturalShadow(.pronounced) + } + + // Title + VStack(spacing: 10) { + Text("Join a Residence") + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text("Enter the 6-character code shared with you to join an existing home.") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + .padding(.horizontal, 20) + } + + // Code input card + VStack(spacing: 16) { + HStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 40, height: 40) + + Image(systemName: "key.fill") + .font(.system(size: 17, weight: .medium)) + .foregroundColor(Color.appPrimary) + } + + TextField("Enter share code", text: $shareCode) + .font(.system(size: 20, weight: .semibold, design: .monospaced)) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .focused($isCodeFieldFocused) + .onChange(of: shareCode) { _, newValue in + // Limit to 6 characters + if newValue.count > 6 { + shareCode = String(newValue.prefix(6)) + } + // Clear error when typing + errorMessage = nil + } + } + .padding(18) + .background( + ZStack { + Color.appBackgroundSecondary + GrainTexture(opacity: 0.01) + } + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 2) + ) + .naturalShadow(.medium) + } + .padding(.horizontal, OrganicSpacing.comfortable) + + // Error message + if let error = errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(Color.appError) + Text(error) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appError) + Spacer() + } + .padding(14) + .background(Color.appError.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, OrganicSpacing.comfortable) + } + + // Loading indicator + if isLoading { + HStack(spacing: 10) { + ProgressView() + .tint(Color.appPrimary) + Text("Joining residence...") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + } + } + + Spacer() + + // Join button + Button(action: joinResidence) { + HStack(spacing: 10) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + Text(isLoading ? "Joining..." : "Join Residence") + .font(.system(size: 17, weight: .semibold)) + } .frame(maxWidth: .infinity) .frame(height: 56) .foregroundColor(Color.appTextOnPrimary) .background( isCodeValid && !isLoading - ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) - : AnyShapeStyle(Color.appTextSecondary) + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing)) + : AnyShapeStyle(Color.appTextSecondary.opacity(0.4)) ) - .cornerRadius(AppRadius.md) - .shadow(color: isCodeValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(isCodeValid ? .medium : .subtle) + } + .disabled(!isCodeValid || isLoading) + .padding(.horizontal, OrganicSpacing.comfortable) + .padding(.bottom, OrganicSpacing.airy) } - .disabled(!isCodeValid || isLoading) - .padding(.horizontal, AppSpacing.xl) - .padding(.bottom, AppSpacing.xxxl) } - .background(Color.appBackgroundPrimary) .onAppear { + isAnimating = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { isCodeFieldFocused = true } @@ -161,24 +253,26 @@ struct OnboardingJoinResidenceView: View { var onSkip: () -> Void var body: some View { - VStack(spacing: 0) { - // Navigation bar - HStack { - Spacer() + ZStack { + WarmGradientBackground() - Button(action: onSkip) { - Text("Skip") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(Color.appTextSecondary) + VStack(spacing: 0) { + // Navigation bar + HStack { + Spacer() + + Button(action: onSkip) { + Text("Skip") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } } - } - .padding(.horizontal, AppSpacing.lg) - .padding(.vertical, AppSpacing.md) + .padding(.horizontal, 20) + .padding(.vertical, 12) - OnboardingJoinResidenceContent(onJoined: onJoined) + OnboardingJoinResidenceContent(onJoined: onJoined) + } } - .background(Color.appBackgroundPrimary) } } diff --git a/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift b/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift index ee6f318..02f5d92 100644 --- a/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingNameResidenceView.swift @@ -7,6 +7,8 @@ struct OnboardingNameResidenceContent: View { @FocusState private var isTextFieldFocused: Bool @State private var showSuggestions = false + @State private var isAnimating = false + @Environment(\.colorScheme) var colorScheme private var isValid: Bool { !residenceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -21,177 +23,240 @@ struct OnboardingNameResidenceContent: View { ] var body: some View { - VStack(spacing: 0) { - Spacer() + ZStack { + WarmGradientBackground() - // Content - VStack(spacing: AppSpacing.xl) { - // Animated house icon - ZStack { - // Colorful background circles - Circle() - .fill( - RadialGradient( - colors: [Color.appAccent.opacity(0.2), Color.clear], - center: .center, - startRadius: 30, - endRadius: 80 - ) + // Decorative blobs + GeometryReader { geo in + OrganicBlobShape(variation: 2) + .fill( + RadialGradient( + colors: [ + Color.appAccent.opacity(0.08), + Color.appAccent.opacity(0.02), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.35 ) - .frame(width: 160, height: 160) - .offset(x: -20, y: -20) + ) + .frame(width: geo.size.width * 0.6, height: geo.size.height * 0.35) + .offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.05) + .blur(radius: 25) - Circle() - .fill( - RadialGradient( - colors: [Color.appPrimary.opacity(0.2), Color.clear], - center: .center, - startRadius: 30, - endRadius: 80 - ) + OrganicBlobShape(variation: 0) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.06), + Color.appPrimary.opacity(0.01), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.3 ) - .frame(width: 160, height: 160) - .offset(x: 20, y: 20) + ) + .frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3) + .offset(x: geo.size.width * 0.55, y: geo.size.height * 0.6) + .blur(radius: 20) + } - // Main icon - Image("icon") - .resizable() - .scaledToFit() - .frame(width: 100, height: 100) - .shadow(color: Color.appPrimary.opacity(0.3), radius: 15, y: 8) - } + VStack(spacing: 0) { + Spacer() - // Title with playful wording - VStack(spacing: AppSpacing.md) { - Text("Let's give your place a name!") - .font(.title) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) - .multilineTextAlignment(.center) - .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle) - - Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - .lineSpacing(4) - } - - // Text field with gradient border when focused - VStack(alignment: .leading, spacing: AppSpacing.sm) { - HStack(spacing: AppSpacing.sm) { - Image(systemName: "house.fill") - .font(.title3) - .foregroundStyle( - LinearGradient( - colors: [Color.appPrimary, Color.appAccent], - startPoint: .topLeading, - endPoint: .bottomTrailing + // Content + VStack(spacing: OrganicSpacing.comfortable) { + // Animated house icon + ZStack { + // Pulsing glow circles + Circle() + .fill( + RadialGradient( + colors: [Color.appAccent.opacity(0.15), Color.clear], + center: .center, + startRadius: 30, + endRadius: 80 ) ) - .frame(width: 24) + .frame(width: 160, height: 160) + .offset(x: -20, y: -20) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true), + value: isAnimating + ) - TextField("The Smith Residence", text: $residenceName) - .font(.body) - .fontWeight(.medium) - .textInputAutocapitalization(.words) - .focused($isTextFieldFocused) - .submitLabel(.continue) - .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField) - .onSubmit { - if isValid { - onContinue() + Circle() + .fill( + RadialGradient( + colors: [Color.appPrimary.opacity(0.15), Color.clear], + center: .center, + startRadius: 30, + endRadius: 80 + ) + ) + .frame(width: 160, height: 160) + .offset(x: 20, y: 20) + .scaleEffect(isAnimating ? 0.95 : 1.05) + .animation( + Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5), + value: isAnimating + ) + + // Main icon + Image("icon") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .naturalShadow(.pronounced) + } + + // Title with playful wording + VStack(spacing: 12) { + Text("Let's give your place a name!") + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + .multilineTextAlignment(.center) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle) + + Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + + // Text field with organic styling + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 14) { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appPrimary.opacity(0.15), Color.appAccent.opacity(0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 40, height: 40) + + Image(systemName: "house.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle( + LinearGradient( + colors: [Color.appPrimary, Color.appAccent], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + + TextField("The Smith Residence", text: $residenceName) + .font(.system(size: 17, weight: .medium)) + .textInputAutocapitalization(.words) + .focused($isTextFieldFocused) + .submitLabel(.continue) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField) + .onSubmit { + if isValid { + onContinue() + } + } + + if !residenceName.isEmpty { + Button(action: { residenceName = "" }) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 20)) + .foregroundColor(Color.appTextSecondary.opacity(0.5)) } } - - if !residenceName.isEmpty { - Button(action: { residenceName = "" }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(Color.appTextSecondary.opacity(0.5)) - } } - } - .padding(AppSpacing.lg) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.lg) - .overlay( - RoundedRectangle(cornerRadius: AppRadius.lg) - .stroke( - isTextFieldFocused - ? LinearGradient(colors: [Color.appPrimary, Color.appAccent], startPoint: .leading, endPoint: .trailing) - : LinearGradient(colors: [Color.appTextSecondary.opacity(0.3), Color.appTextSecondary.opacity(0.3)], startPoint: .leading, endPoint: .trailing), - lineWidth: 2 - ) - ) - .shadow(color: isTextFieldFocused ? Color.appPrimary.opacity(0.15) : .clear, radius: 12, y: 4) - .animation(.easeInOut(duration: 0.2), value: isTextFieldFocused) + .padding(18) + .background( + ZStack { + Color.appBackgroundSecondary + GrainTexture(opacity: 0.01) + } + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke( + isTextFieldFocused + ? LinearGradient(colors: [Color.appPrimary, Color.appAccent], startPoint: .leading, endPoint: .trailing) + : LinearGradient(colors: [Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0.2)], startPoint: .leading, endPoint: .trailing), + lineWidth: 2 + ) + ) + .naturalShadow(isTextFieldFocused ? .medium : .subtle) + .animation(.easeInOut(duration: 0.2), value: isTextFieldFocused) - // Name suggestions - if residenceName.isEmpty { - VStack(alignment: .leading, spacing: AppSpacing.xs) { - Text("Need inspiration?") - .font(.caption) - .fontWeight(.medium) - .foregroundColor(Color.appTextSecondary) - .padding(.top, AppSpacing.xs) + // Name suggestions + if residenceName.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Need inspiration?") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color.appTextSecondary) + .padding(.top, 4) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: AppSpacing.sm) { - ForEach(nameSuggestions, id: \.self) { suggestion in - Button(action: { - withAnimation(.spring(response: 0.3)) { - residenceName = suggestion + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(nameSuggestions, id: \.self) { suggestion in + Button(action: { + withAnimation(.spring(response: 0.3)) { + residenceName = suggestion + } + }) { + Text(suggestion) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color.appPrimary) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.appPrimary.opacity(0.1)) + .clipShape(Capsule()) } - }) { - Text(suggestion) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(Color.appPrimary) - .padding(.horizontal, AppSpacing.md) - .padding(.vertical, AppSpacing.sm) - .background(Color.appPrimary.opacity(0.1)) - .cornerRadius(AppRadius.md) } } } } } } + .padding(.horizontal, OrganicSpacing.comfortable) } - .padding(.horizontal, AppSpacing.xl) - } - Spacer() + Spacer() - // Continue button - Button(action: onContinue) { - HStack(spacing: AppSpacing.sm) { - Text("That's Perfect!") - .font(.headline) - .fontWeight(.bold) + // Continue button + Button(action: onContinue) { + HStack(spacing: 10) { + Text("That's Perfect!") + .font(.system(size: 17, weight: .bold)) - Image(systemName: "arrow.right") - .font(.headline) + Image(systemName: "arrow.right") + .font(.system(size: 16, weight: .bold)) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + isValid + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing)) + : AnyShapeStyle(Color.appTextSecondary.opacity(0.4)) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(isValid ? .medium : .subtle) } - .frame(maxWidth: .infinity) - .frame(height: 56) - .foregroundColor(Color.appTextOnPrimary) - .background( - isValid - ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing)) - : AnyShapeStyle(Color.appTextSecondary.opacity(0.5)) - ) - .cornerRadius(AppRadius.lg) - .shadow(color: isValid ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton) + .disabled(!isValid) + .padding(.horizontal, OrganicSpacing.comfortable) + .padding(.bottom, OrganicSpacing.airy) + .animation(.easeInOut(duration: 0.2), value: isValid) } - .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton) - .disabled(!isValid) - .padding(.horizontal, AppSpacing.xl) - .padding(.bottom, AppSpacing.xxxl) - .animation(.easeInOut(duration: 0.2), value: isValid) } - .background(Color.appBackgroundPrimary) .onAppear { + isAnimating = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { isTextFieldFocused = true } @@ -207,35 +272,44 @@ struct OnboardingNameResidenceView: View { var onBack: () -> Void var body: some View { - VStack(spacing: 0) { - // Navigation bar - HStack { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.title2) - .foregroundColor(Color.appPrimary) + ZStack { + WarmGradientBackground() + + VStack(spacing: 0) { + // Navigation bar + HStack { + Button(action: onBack) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 36, height: 36) + + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + } + + Spacer() + + OnboardingProgressIndicator(currentStep: 2, totalSteps: 5) + + Spacer() + + // Invisible spacer for alignment + Circle() + .fill(Color.clear) + .frame(width: 36, height: 36) } + .padding(.horizontal, 20) + .padding(.vertical, 12) - Spacer() - - OnboardingProgressIndicator(currentStep: 2, totalSteps: 5) - - Spacer() - - // Invisible spacer for alignment - Image(systemName: "chevron.left") - .font(.title2) - .opacity(0) + OnboardingNameResidenceContent( + residenceName: $residenceName, + onContinue: onContinue + ) } - .padding(.horizontal, AppSpacing.lg) - .padding(.vertical, AppSpacing.md) - - OnboardingNameResidenceContent( - residenceName: $residenceName, - onContinue: onContinue - ) } - .background(Color.appBackgroundPrimary) } } diff --git a/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift index 40354a6..3229af5 100644 --- a/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift @@ -8,6 +8,7 @@ struct OnboardingSubscriptionContent: View { @State private var isLoading = false @State private var selectedPlan: PricingPlan = .yearly @State private var animateBadge = false + @Environment(\.colorScheme) var colorScheme private let benefits: [SubscriptionBenefit] = [ SubscriptionBenefit( @@ -49,181 +50,233 @@ struct OnboardingSubscriptionContent: View { ] var body: some View { - ScrollView { - VStack(spacing: AppSpacing.xl) { - // Header with animated crown - VStack(spacing: AppSpacing.md) { - ZStack { - // Glow effect - Circle() - .fill( - RadialGradient( - colors: [Color.appAccent.opacity(0.3), Color.clear], - center: .center, - startRadius: 30, - endRadius: 100 - ) - ) - .frame(width: 180, height: 180) - .scaleEffect(animateBadge ? 1.1 : 1.0) - .animation(.easeInOut(duration: 2).repeatForever(autoreverses: true), value: animateBadge) + ZStack { + WarmGradientBackground() - // Crown icon - ZStack { - Circle() - .fill( - LinearGradient( - colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 100, height: 100) - - Image(systemName: "crown.fill") - .font(.system(size: 44)) - .foregroundColor(.white) - } - .shadow(color: Color.appAccent.opacity(0.5), radius: 20, y: 10) - } - - // Pro badge - HStack(spacing: AppSpacing.xs) { - Image(systemName: "sparkles") - .foregroundColor(Color.appAccent) - Text("CASERA PRO") - .font(.headline) - .fontWeight(.black) - .foregroundColor(Color.appAccent) - Image(systemName: "sparkles") - .foregroundColor(Color.appAccent) - } - .padding(.horizontal, AppSpacing.lg) - .padding(.vertical, AppSpacing.sm) - .background( - LinearGradient( - colors: [Color.appAccent.opacity(0.15), Color(hex: "#FF9500")?.opacity(0.15) ?? Color.orange.opacity(0.15)], - startPoint: .leading, - endPoint: .trailing + // Decorative blobs + GeometryReader { geo in + OrganicBlobShape(variation: 0) + .fill( + RadialGradient( + colors: [ + Color.appAccent.opacity(0.08), + Color.appAccent.opacity(0.02), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.35 ) ) - .clipShape(Capsule()) + .frame(width: geo.size.width * 0.6, height: geo.size.height * 0.3) + .offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.05) + .blur(radius: 25) - Text("Take your home management\nto the next level") - .font(.title2) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) - .multilineTextAlignment(.center) - .lineSpacing(4) + OrganicBlobShape(variation: 2) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.06), + Color.appPrimary.opacity(0.01), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.25 + ) + ) + .frame(width: geo.size.width * 0.4, height: geo.size.height * 0.2) + .offset(x: geo.size.width * 0.65, y: geo.size.height * 0.7) + .blur(radius: 20) + } - // Social proof - HStack(spacing: AppSpacing.xs) { - ForEach(0..<5, id: \.self) { _ in - Image(systemName: "star.fill") - .font(.caption) + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.comfortable) { + // Header with animated crown + VStack(spacing: 16) { + ZStack { + // Pulsing glow effect + Circle() + .fill( + RadialGradient( + colors: [Color.appAccent.opacity(0.25), Color.appAccent.opacity(0.05), Color.clear], + center: .center, + startRadius: 30, + endRadius: 100 + ) + ) + .frame(width: 180, height: 180) + .scaleEffect(animateBadge ? 1.1 : 1.0) + .animation(.easeInOut(duration: 2).repeatForever(autoreverses: true), value: animateBadge) + + // Crown icon + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 100, height: 100) + + Image(systemName: "crown.fill") + .font(.system(size: 44)) + .foregroundColor(.white) + } + .naturalShadow(.pronounced) + } + + // Pro badge + HStack(spacing: 6) { + Image(systemName: "sparkles") + .foregroundColor(Color.appAccent) + Text("CASERA PRO") + .font(.system(size: 14, weight: .black)) + .foregroundColor(Color.appAccent) + Image(systemName: "sparkles") .foregroundColor(Color.appAccent) } - Text("4.9") - .font(.subheadline) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) - Text("• 10K+ homeowners") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - } - } - .padding(.top, AppSpacing.lg) - - // Benefits list with gradient icons - VStack(spacing: AppSpacing.sm) { - ForEach(benefits) { benefit in - SubscriptionBenefitRow(benefit: benefit) - } - } - .padding(.horizontal, AppSpacing.lg) - - // Pricing plans - VStack(spacing: AppSpacing.md) { - Text("Choose your plan") - .font(.headline) - .fontWeight(.semibold) - .foregroundColor(Color.appTextPrimary) - - // Yearly plan (best value) - PricingPlanCard( - plan: .yearly, - isSelected: selectedPlan == .yearly, - onSelect: { selectedPlan = .yearly } - ) - - // Monthly plan - PricingPlanCard( - plan: .monthly, - isSelected: selectedPlan == .monthly, - onSelect: { selectedPlan = .monthly } - ) - } - .padding(.horizontal, AppSpacing.lg) - - // CTA buttons - VStack(spacing: AppSpacing.md) { - Button(action: startFreeTrial) { - HStack(spacing: AppSpacing.sm) { - if isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } else { - Text("Start 7-Day Free Trial") - .font(.headline) - .fontWeight(.bold) - - Image(systemName: "arrow.right") - .font(.headline) - } - } - .frame(maxWidth: .infinity) - .frame(height: 56) - .foregroundColor(Color.appTextOnPrimary) + .padding(.horizontal, 18) + .padding(.vertical, 10) .background( LinearGradient( - colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], + colors: [Color.appAccent.opacity(0.15), Color(hex: "#FF9500")?.opacity(0.15) ?? Color.orange.opacity(0.15)], startPoint: .leading, endPoint: .trailing ) ) - .cornerRadius(AppRadius.lg) - .shadow(color: Color.appAccent.opacity(0.4), radius: 15, y: 8) - } - .disabled(isLoading) + .clipShape(Capsule()) - // Continue without - Button(action: { - onSubscribe() - }) { - Text("Continue with Free") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(Color.appTextSecondary) - } + Text("Take your home management\nto the next level") + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + .multilineTextAlignment(.center) + .lineSpacing(4) - // Legal text - VStack(spacing: AppSpacing.xs) { - Text("7-day free trial, then \(selectedPlan == .yearly ? "$23.99/year" : "$2.99/month")") - .font(.caption) - .foregroundColor(Color.appTextSecondary) - - Text("Cancel anytime in Settings • No commitment") - .font(.caption) - .foregroundColor(Color.appTextSecondary.opacity(0.7)) + // Social proof + HStack(spacing: 6) { + ForEach(0..<5, id: \.self) { _ in + Image(systemName: "star.fill") + .font(.system(size: 12)) + .foregroundColor(Color.appAccent) + } + Text("4.9") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(Color.appTextPrimary) + Text("• 10K+ homeowners") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } } - .multilineTextAlignment(.center) - .padding(.top, AppSpacing.xs) + .padding(.top, OrganicSpacing.comfortable) + + // Benefits list card + VStack(spacing: 10) { + ForEach(benefits) { benefit in + OrganicSubscriptionBenefitRow(benefit: benefit) + } + } + .padding(OrganicSpacing.cozy) + .background( + ZStack { + Color.appBackgroundSecondary + + GeometryReader { geo in + OrganicBlobShape(variation: 1) + .fill(Color.appAccent.opacity(colorScheme == .dark ? 0.06 : 0.04)) + .frame(width: geo.size.width * 0.4, height: geo.size.height * 0.5) + .offset(x: geo.size.width * 0.65, y: geo.size.height * 0.3) + .blur(radius: 20) + } + + GrainTexture(opacity: 0.015) + } + ) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .naturalShadow(.medium) + .padding(.horizontal, 20) + + // Pricing plans + VStack(spacing: 14) { + Text("Choose your plan") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(Color.appTextPrimary) + + // Yearly plan (best value) + OrganicPricingPlanCard( + plan: .yearly, + isSelected: selectedPlan == .yearly, + onSelect: { selectedPlan = .yearly } + ) + + // Monthly plan + OrganicPricingPlanCard( + plan: .monthly, + isSelected: selectedPlan == .monthly, + onSelect: { selectedPlan = .monthly } + ) + } + .padding(.horizontal, 20) + + // CTA buttons + VStack(spacing: 14) { + Button(action: startFreeTrial) { + HStack(spacing: 10) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Text("Start 7-Day Free Trial") + .font(.system(size: 17, weight: .bold)) + + Image(systemName: "arrow.right") + .font(.system(size: 16, weight: .bold)) + } + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + LinearGradient( + colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], + startPoint: .leading, + endPoint: .trailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.medium) + } + .disabled(isLoading) + + // Continue without + Button(action: { + onSubscribe() + }) { + Text("Continue with Free") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color.appTextSecondary) + } + + // Legal text + VStack(spacing: 4) { + Text("7-day free trial, then \(selectedPlan == .yearly ? "$23.99/year" : "$2.99/month")") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + + Text("Cancel anytime in Settings • No commitment") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary.opacity(0.7)) + } + .multilineTextAlignment(.center) + .padding(.top, 4) + } + .padding(.horizontal, OrganicSpacing.comfortable) + .padding(.bottom, OrganicSpacing.airy) } - .padding(.horizontal, AppSpacing.xl) - .padding(.bottom, AppSpacing.xxxl) } } - .background(Color.appBackgroundPrimary) .onAppear { animateBadge = true } @@ -296,13 +349,15 @@ enum PricingPlan { } } -// MARK: - Pricing Plan Card +// MARK: - Organic Pricing Plan Card -struct PricingPlanCard: View { +private struct OrganicPricingPlanCard: View { let plan: PricingPlan let isSelected: Bool var onSelect: () -> Void + @Environment(\.colorScheme) var colorScheme + var body: some View { Button(action: onSelect) { HStack { @@ -320,19 +375,17 @@ struct PricingPlanCard: View { } VStack(alignment: .leading, spacing: 2) { - HStack(spacing: AppSpacing.sm) { + HStack(spacing: 8) { Text(plan.title) - .font(.headline) - .fontWeight(.semibold) + .font(.system(size: 16, weight: .semibold)) .foregroundColor(Color.appTextPrimary) if let savings = plan.savings { Text(savings) - .font(.caption) - .fontWeight(.bold) + .font(.system(size: 10, weight: .bold)) .foregroundColor(.white) - .padding(.horizontal, AppSpacing.sm) - .padding(.vertical, 2) + .padding(.horizontal, 8) + .padding(.vertical, 3) .background( LinearGradient( colors: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green], @@ -346,7 +399,7 @@ struct PricingPlanCard: View { if let monthlyEquivalent = plan.monthlyEquivalent { Text(monthlyEquivalent) - .font(.caption) + .font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary) } } @@ -355,28 +408,43 @@ struct PricingPlanCard: View { VStack(alignment: .trailing, spacing: 0) { Text(plan.price) - .font(.title3) - .fontWeight(.bold) + .font(.system(size: 20, weight: .bold)) .foregroundColor(isSelected ? Color.appAccent : Color.appTextPrimary) Text(plan.period) - .font(.caption) + .font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary) } } - .padding(AppSpacing.lg) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.lg) + .padding(18) + .background( + ZStack { + Color.appBackgroundSecondary + + if plan == .yearly { + GeometryReader { geo in + OrganicBlobShape(variation: 1) + .fill(Color.appAccent.opacity(colorScheme == .dark ? 0.06 : 0.04)) + .frame(width: geo.size.width * 0.3, height: geo.size.height * 0.8) + .offset(x: geo.size.width * 0.75, y: 0) + .blur(radius: 10) + } + } + + GrainTexture(opacity: 0.01) + } + ) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: AppRadius.lg) + RoundedRectangle(cornerRadius: 18, style: .continuous) .stroke( isSelected ? LinearGradient(colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], startPoint: .leading, endPoint: .trailing) - : LinearGradient(colors: [Color.clear, Color.clear], startPoint: .leading, endPoint: .trailing), - lineWidth: 2 + : LinearGradient(colors: [Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0.2)], startPoint: .leading, endPoint: .trailing), + lineWidth: isSelected ? 2 : 1 ) ) - .shadow(color: isSelected ? Color.appAccent.opacity(0.15) : .clear, radius: 10, y: 4) + .naturalShadow(isSelected ? .medium : .subtle) } .buttonStyle(.plain) .animation(.easeInOut(duration: 0.2), value: isSelected) @@ -404,13 +472,13 @@ struct SubscriptionBenefit: Identifiable { let gradient: [Color] } -// MARK: - Subscription Benefit Row +// MARK: - Organic Subscription Benefit Row -struct SubscriptionBenefitRow: View { +private struct OrganicSubscriptionBenefitRow: View { let benefit: SubscriptionBenefit var body: some View { - HStack(spacing: AppSpacing.md) { + HStack(spacing: 14) { // Gradient icon ZStack { Circle() @@ -421,22 +489,21 @@ struct SubscriptionBenefitRow: View { endPoint: .bottomTrailing ) ) - .frame(width: 44, height: 44) + .frame(width: 40, height: 40) Image(systemName: benefit.icon) - .font(.title3) + .font(.system(size: 17, weight: .medium)) .foregroundColor(.white) } - .shadow(color: benefit.gradient[0].opacity(0.3), radius: 8, y: 4) + .naturalShadow(.subtle) VStack(alignment: .leading, spacing: 2) { Text(benefit.title) - .font(.subheadline) - .fontWeight(.semibold) + .font(.system(size: 14, weight: .semibold)) .foregroundColor(Color.appTextPrimary) Text(benefit.description) - .font(.caption) + .font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary) .lineLimit(2) } @@ -444,12 +511,11 @@ struct SubscriptionBenefitRow: View { Spacer() Image(systemName: "checkmark") - .font(.caption) - .fontWeight(.bold) + .font(.system(size: 12, weight: .bold)) .foregroundColor(benefit.gradient[0]) } - .padding(.horizontal, AppSpacing.md) - .padding(.vertical, AppSpacing.sm) + .padding(.horizontal, 4) + .padding(.vertical, 6) } } diff --git a/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift b/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift index 9f000ec..7801a1c 100644 --- a/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift @@ -56,56 +56,58 @@ struct OnboardingValuePropsContent: View { ] var body: some View { - VStack(spacing: 0) { - // Feature cards in a tab view - TabView(selection: $currentPage) { - ForEach(Array(features.enumerated()), id: \.offset) { index, feature in - FeatureCard(feature: feature, isActive: currentPage == index) - .tag(index) - .padding(.horizontal, AppSpacing.lg) - } - } - .tabViewStyle(.page(indexDisplayMode: .never)) - .frame(maxHeight: .infinity) + ZStack { + WarmGradientBackground() - // Custom page indicator - HStack(spacing: AppSpacing.sm) { - ForEach(0.. Void var body: some View { - VStack(spacing: 0) { - // Navigation bar - HStack { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.title2) - .foregroundColor(Color.appPrimary) + ZStack { + WarmGradientBackground() + + VStack(spacing: 0) { + // Navigation bar + HStack { + Button(action: onBack) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 36, height: 36) + + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + } + + Spacer() + + OnboardingProgressIndicator(currentStep: 1, totalSteps: 5) + + Spacer() + + Button(action: onSkip) { + Text("Skip") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } } + .padding(.horizontal, 20) + .padding(.vertical, 12) - Spacer() - - OnboardingProgressIndicator(currentStep: 1, totalSteps: 5) - - Spacer() - - Button(action: onSkip) { - Text("Skip") - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(Color.appTextSecondary) - } + OnboardingValuePropsContent(onContinue: onContinue) } - .padding(.horizontal, AppSpacing.lg) - .padding(.vertical, AppSpacing.md) - - OnboardingValuePropsContent(onContinue: onContinue) } - .background(Color.appBackgroundPrimary) } } diff --git a/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift index 8f68945..de5403a 100644 --- a/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingVerifyEmailView.swift @@ -8,133 +8,232 @@ struct OnboardingVerifyEmailContent: View { @StateObject private var viewModel = VerifyEmailViewModel() @FocusState private var isCodeFieldFocused: Bool @State private var hasCalledOnVerified = false + @State private var isAnimating = false + @Environment(\.colorScheme) var colorScheme var body: some View { - VStack(spacing: 0) { - Spacer() + ZStack { + WarmGradientBackground() - // Content - VStack(spacing: AppSpacing.xl) { - // Icon - ZStack { - Circle() - .fill(Color.appPrimary.opacity(0.1)) - .frame(width: 100, height: 100) - - Image(systemName: "envelope.badge.fill") - .font(.system(size: 44)) - .foregroundStyle(Color.appPrimary.gradient) - } - - // Title - VStack(spacing: AppSpacing.sm) { - Text("Verify your email") - .font(.title2) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) - .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle) - - Text("We sent a 6-digit code to your email address. Enter it below to verify your account.") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - } - - // Code input - VStack(alignment: .leading, spacing: AppSpacing.xs) { - HStack(spacing: AppSpacing.sm) { - Image(systemName: "key.fill") - .foregroundColor(Color.appTextSecondary) - .frame(width: 20) - - TextField("Enter 6-digit code", text: $viewModel.code) - .keyboardType(.numberPad) - .textContentType(.oneTimeCode) - .focused($isCodeFieldFocused) - .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField) - .keyboardDismissToolbar() - .onChange(of: viewModel.code) { _, newValue in - // Limit to 6 digits - if newValue.count > 6 { - viewModel.code = String(newValue.prefix(6)) - } - // Auto-verify when 6 digits entered - if newValue.count == 6 { - viewModel.verifyEmail() - } - } - } - .padding(AppSpacing.md) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.md) - .overlay( - RoundedRectangle(cornerRadius: AppRadius.md) - .stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + // Decorative blobs + GeometryReader { geo in + OrganicBlobShape(variation: 0) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.06), + Color.appPrimary.opacity(0.01), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.3 + ) ) - } - .padding(.horizontal, AppSpacing.xl) + .frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3) + .offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.1) + .blur(radius: 20) - // Error message - if let error = viewModel.errorMessage { - HStack(spacing: AppSpacing.sm) { - Image(systemName: "exclamationmark.circle.fill") - .foregroundColor(Color.appError) - Text(error) - .font(.callout) - .foregroundColor(Color.appError) - Spacer() - } - .padding(AppSpacing.md) - .background(Color.appError.opacity(0.1)) - .cornerRadius(AppRadius.md) - .padding(.horizontal, AppSpacing.xl) - } - - // Loading indicator - if viewModel.isLoading { - HStack { - ProgressView() - Text("Verifying...") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - } - } - - // Resend code hint - Text("Didn't receive a code? Check your spam folder or re-register") - .font(.caption) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) + OrganicBlobShape(variation: 2) + .fill( + RadialGradient( + colors: [ + Color.appAccent.opacity(0.05), + Color.appAccent.opacity(0.01), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.25 + ) + ) + .frame(width: geo.size.width * 0.4, height: geo.size.height * 0.25) + .offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65) + .blur(radius: 15) } - Spacer() + VStack(spacing: 0) { + Spacer() - // Verify button - Button(action: { - viewModel.verifyEmail() - }) { - Text("Verify") - .font(.headline) - .fontWeight(.semibold) + // Content + VStack(spacing: OrganicSpacing.comfortable) { + // Icon with pulsing glow + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.15), + Color.appPrimary.opacity(0.05), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 70 + ) + ) + .frame(width: 140, height: 140) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true), + value: isAnimating + ) + + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 90, height: 90) + + Image(systemName: "envelope.badge.fill") + .font(.system(size: 40, weight: .medium)) + .foregroundColor(.white) + } + .naturalShadow(.pronounced) + } + + // Title + VStack(spacing: 10) { + Text("Verify your email") + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle) + + Text("We sent a 6-digit code to your email address. Enter it below to verify your account.") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + .padding(.horizontal, 20) + } + + // Code input card + VStack(spacing: 16) { + HStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 40, height: 40) + + Image(systemName: "key.fill") + .font(.system(size: 17, weight: .medium)) + .foregroundColor(Color.appPrimary) + } + + TextField("Enter 6-digit code", text: $viewModel.code) + .font(.system(size: 20, weight: .semibold, design: .monospaced)) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .focused($isCodeFieldFocused) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField) + .keyboardDismissToolbar() + .onChange(of: viewModel.code) { _, newValue in + // Limit to 6 digits + if newValue.count > 6 { + viewModel.code = String(newValue.prefix(6)) + } + // Auto-verify when 6 digits entered + if newValue.count == 6 { + viewModel.verifyEmail() + } + } + } + .padding(18) + .background( + ZStack { + Color.appBackgroundSecondary + GrainTexture(opacity: 0.01) + } + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 2) + ) + .naturalShadow(.medium) + } + .padding(.horizontal, OrganicSpacing.comfortable) + + // Error message + if let error = viewModel.errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(Color.appError) + Text(error) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appError) + Spacer() + } + .padding(14) + .background(Color.appError.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, OrganicSpacing.comfortable) + } + + // Loading indicator + if viewModel.isLoading { + HStack(spacing: 10) { + ProgressView() + .tint(Color.appPrimary) + Text("Verifying...") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + } + + // Resend code hint + HStack(spacing: 6) { + Image(systemName: "info.circle.fill") + .font(.system(size: 13)) + .foregroundColor(Color.appTextSecondary.opacity(0.7)) + + Text("Didn't receive a code? Check your spam folder or re-register") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + .padding(.top, 8) + } + + Spacer() + + // Verify button + Button(action: { + viewModel.verifyEmail() + }) { + HStack(spacing: 10) { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + Text(viewModel.isLoading ? "Verifying..." : "Verify") + .font(.system(size: 17, weight: .semibold)) + } .frame(maxWidth: .infinity) .frame(height: 56) .foregroundColor(Color.appTextOnPrimary) .background( viewModel.code.count == 6 && !viewModel.isLoading - ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) - : AnyShapeStyle(Color.appTextSecondary) + ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing)) + : AnyShapeStyle(Color.appTextSecondary.opacity(0.4)) ) - .cornerRadius(AppRadius.md) - .shadow(color: viewModel.code.count == 6 ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(viewModel.code.count == 6 ? .medium : .subtle) + } + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyButton) + .disabled(viewModel.code.count != 6 || viewModel.isLoading) + .padding(.horizontal, OrganicSpacing.comfortable) + .padding(.bottom, OrganicSpacing.airy) } - .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyButton) - .disabled(viewModel.code.count != 6 || viewModel.isLoading) - .padding(.horizontal, AppSpacing.xl) - .padding(.bottom, AppSpacing.xxxl) } - .background(Color.appBackgroundPrimary) .onAppear { print("🏠 ONBOARDING: OnboardingVerifyEmailContent appeared") + isAnimating = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { isCodeFieldFocused = true } @@ -159,33 +258,40 @@ struct OnboardingVerifyEmailView: View { var onLogout: () -> Void var body: some View { - VStack(spacing: 0) { - // Navigation bar - HStack { - // Logout option - Button(action: onLogout) { - Text("Back") - .font(.subheadline) + ZStack { + WarmGradientBackground() + + VStack(spacing: 0) { + // Navigation bar + HStack { + // Logout option + Button(action: onLogout) { + HStack(spacing: 6) { + Image(systemName: "arrow.left") + .font(.system(size: 14, weight: .medium)) + Text("Back") + .font(.system(size: 15, weight: .medium)) + } .foregroundColor(Color.appPrimary) + } + + Spacer() + + OnboardingProgressIndicator(currentStep: 4, totalSteps: 5) + + Spacer() + + // Invisible spacer for alignment + Text("Back") + .font(.system(size: 15, weight: .medium)) + .opacity(0) } + .padding(.horizontal, 20) + .padding(.vertical, 12) - Spacer() - - OnboardingProgressIndicator(currentStep: 4, totalSteps: 5) - - Spacer() - - // Invisible spacer for alignment - Text("Back") - .font(.subheadline) - .opacity(0) + OnboardingVerifyEmailContent(onVerified: onVerified) } - .padding(.horizontal, AppSpacing.lg) - .padding(.vertical, AppSpacing.md) - - OnboardingVerifyEmailContent(onVerified: onVerified) } - .background(Color.appBackgroundPrimary) } } diff --git a/iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift b/iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift index ee779fc..87d4211 100644 --- a/iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingWelcomeView.swift @@ -7,104 +7,190 @@ struct OnboardingWelcomeView: View { var onLogin: () -> Void @State private var showingLoginSheet = false + @State private var isAnimating = false + @State private var iconScale: CGFloat = 0.8 + @State private var iconOpacity: Double = 0 var body: some View { - VStack(spacing: 0) { - Spacer() + ZStack { + WarmGradientBackground() - // Hero section - VStack(spacing: AppSpacing.xl) { - // App icon - Image("icon") - .resizable() - .scaledToFit() - .frame(width: 120, height: 120) - .clipShape(RoundedRectangle(cornerRadius: AppRadius.xxl)) - .shadow(color: Color.appPrimary.opacity(0.3), radius: 20, y: 10) + // Decorative blobs + GeometryReader { geo in + OrganicBlobShape(variation: 0) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.08), + Color.appPrimary.opacity(0.02), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.4 + ) + ) + .frame(width: geo.size.width * 0.7, height: geo.size.height * 0.4) + .offset(x: -geo.size.width * 0.2, y: geo.size.height * 0.1) + .blur(radius: 30) - // Welcome text - VStack(spacing: AppSpacing.sm) { - Text("Welcome to Casera") - .font(.largeTitle) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) - .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle) - - Text("Your home maintenance companion") - .font(.title3) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - } + OrganicBlobShape(variation: 1) + .fill( + RadialGradient( + colors: [ + Color.appAccent.opacity(0.06), + Color.appAccent.opacity(0.01), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.3 + ) + ) + .frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3) + .offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65) + .blur(radius: 25) } - Spacer() + VStack(spacing: 0) { + Spacer() - // Action buttons - VStack(spacing: AppSpacing.md) { - // Primary CTA - Start Fresh - Button(action: onStartFresh) { - HStack(spacing: AppSpacing.sm) { + // Hero section + VStack(spacing: OrganicSpacing.comfortable) { + // Animated icon with glow + ZStack { + // Outer pulsing glow + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.2), + Color.appPrimary.opacity(0.05), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 100 + ) + ) + .frame(width: 200, height: 200) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true), + value: isAnimating + ) + + // App icon Image("icon") .resizable() .scaledToFit() - .frame(width: 24, height: 24) - Text("Start Fresh") - .font(.headline) - .fontWeight(.semibold) + .frame(width: 120, height: 120) + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + .naturalShadow(.pronounced) + .scaleEffect(iconScale) + .opacity(iconOpacity) } - .frame(maxWidth: .infinity) - .frame(height: 56) - .foregroundColor(Color.appTextOnPrimary) - .background( - LinearGradient( - colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], - startPoint: .topLeading, - endPoint: .bottomTrailing + + // Welcome text + VStack(spacing: 10) { + Text("Welcome to Casera") + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle) + + Text("Your home maintenance companion") + .font(.system(size: 17, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + } + + Spacer() + + // Action buttons + VStack(spacing: 14) { + // Primary CTA - Start Fresh + Button(action: onStartFresh) { + HStack(spacing: 12) { + Image("icon") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + Text("Start Fresh") + .font(.system(size: 17, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) ) - ) - .cornerRadius(AppRadius.md) - .shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5) - } - .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton) - - // Secondary CTA - Join Existing - Button(action: onJoinExisting) { - HStack(spacing: AppSpacing.sm) { - Image(systemName: "person.2.fill") - .font(.title3) - Text("I have a code to join") - .font(.headline) - .fontWeight(.medium) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.medium) } - .frame(maxWidth: .infinity) - .frame(height: 56) - .foregroundColor(Color.appPrimary) - .background(Color.appPrimary.opacity(0.1)) - .cornerRadius(AppRadius.md) - } - .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton) + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton) - // Returning user login - Button(action: { - showingLoginSheet = true - }) { - Text("Already have an account? Log in") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) + // Secondary CTA - Join Existing + Button(action: onJoinExisting) { + HStack(spacing: 12) { + Image(systemName: "person.2.fill") + .font(.system(size: 18, weight: .medium)) + Text("I have a code to join") + .font(.system(size: 17, weight: .medium)) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appPrimary) + .background(Color.appPrimary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.appPrimary.opacity(0.2), lineWidth: 1) + ) + } + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton) + + // Returning user login + Button(action: { + showingLoginSheet = true + }) { + Text("Already have an account? Log in") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton) + .padding(.top, 8) } - .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton) - .padding(.top, AppSpacing.sm) + .padding(.horizontal, OrganicSpacing.comfortable) + .padding(.bottom, OrganicSpacing.airy) + + // Floating leaves decoration + HStack(spacing: 50) { + FloatingLeaf(delay: 0, size: 16, color: Color.appPrimary) + FloatingLeaf(delay: 0.5, size: 12, color: Color.appAccent) + FloatingLeaf(delay: 1.0, size: 18, color: Color.appPrimary) + } + .opacity(0.5) + .padding(.bottom, 20) } - .padding(.horizontal, AppSpacing.xl) - .padding(.bottom, AppSpacing.xxxl) } - .background(Color.appBackgroundPrimary) .sheet(isPresented: $showingLoginSheet) { LoginView(onLoginSuccess: { showingLoginSheet = false onLogin() }) } + .onAppear { + isAnimating = true + withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) { + iconScale = 1.0 + iconOpacity = 1.0 + } + } } } diff --git a/iosApp/iosApp/PasswordReset/ForgotPasswordView.swift b/iosApp/iosApp/PasswordReset/ForgotPasswordView.swift index 3778d2e..cdb11ea 100644 --- a/iosApp/iosApp/PasswordReset/ForgotPasswordView.swift +++ b/iosApp/iosApp/PasswordReset/ForgotPasswordView.swift @@ -7,120 +7,213 @@ struct ForgotPasswordView: View { var body: some View { NavigationView { - Form { - // Header Section - Section { - VStack(spacing: 12) { - Image(systemName: "key.fill") - .font(.system(size: 60)) - .foregroundStyle(Color.appPrimary.gradient) - .padding(.vertical) + ZStack { + WarmGradientBackground() - Text("Forgot Password?") - .font(.title2) - .fontWeight(.bold) + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.spacious) { + Spacer() + .frame(height: OrganicSpacing.comfortable) - Text("Enter your email address and we'll send you a verification code") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical) - } - .listRowBackground(Color.clear) + // 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) - // Email Input Section - Section { - TextField("Email Address", text: $viewModel.email) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.emailAddress) - .focused($isEmailFocused) - .submitLabel(.go) - .onSubmit { - viewModel.requestPasswordReset() - } - .onChange(of: viewModel.email) { _, _ in - viewModel.clearError() - } - } header: { - Text("Email") - } footer: { - Text("We'll send a 6-digit verification code to this address") - } - .listRowBackground(Color.appBackgroundSecondary) - - // Error/Success Messages - if let errorMessage = viewModel.errorMessage { - Section { - Label { - Text(errorMessage) - .foregroundColor(Color.appError) - } icon: { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(Color.appError) - } - } - .listRowBackground(Color.appBackgroundSecondary) - } - - if let successMessage = viewModel.successMessage { - Section { - Label { - Text(successMessage) - .foregroundColor(Color.appAccent) - } icon: { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color.appAccent) - } - } - .listRowBackground(Color.appBackgroundSecondary) - } - - // Send Code Button - Section { - Button(action: { - viewModel.requestPasswordReset() - }) { - HStack { - Spacer() - if viewModel.isLoading { - ProgressView() - } else { - Label("Send Reset Code", systemImage: "envelope.fill") - .fontWeight(.semibold) + Image(systemName: "key.fill") + .font(.system(size: 48, weight: .medium)) + .foregroundColor(Color.appPrimary) } - Spacer() - } - } - .disabled(viewModel.email.isEmpty || viewModel.isLoading) - Button(action: { - dismiss() - }) { - HStack { - Spacer() - Text("Back to Login") - .foregroundColor(Color.appTextSecondary) - Spacer() + VStack(spacing: 8) { + Text("Forgot Password?") + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text("Enter your email address and we'll send you a verification code") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } } + + // Form Card + VStack(spacing: 20) { + // Email Field + VStack(alignment: .leading, spacing: 8) { + Text("EMAIL") + .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: "envelope.fill") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appPrimary) + } + + TextField("Email Address", text: $viewModel.email) + .font(.system(size: 16, weight: .medium)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + .focused($isEmailFocused) + .submitLabel(.go) + .onSubmit { + viewModel.requestPasswordReset() + } + .onChange(of: viewModel.email) { _, _ in + viewModel.clearError() + } + } + .padding(16) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(isEmailFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5) + ) + .animation(.easeInOut(duration: 0.2), value: isEmailFocused) + + Text("We'll send a 6-digit verification code to this address") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + + // 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.appAccent) + Text(successMessage) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appAccent) + Spacer() + } + .padding(16) + .background(Color.appAccent.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + // Send Code Button + Button(action: { + viewModel.requestPasswordReset() + }) { + HStack(spacing: 8) { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Image(systemName: "envelope.fill") + } + Text(viewModel.isLoading ? "Sending..." : "Send Reset Code") + .font(.headline) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + !viewModel.email.isEmpty && !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: !viewModel.email.isEmpty && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear, + radius: 10, + y: 5 + ) + } + .disabled(viewModel.email.isEmpty || viewModel.isLoading) + + // Back to Login + Button(action: { dismiss() }) { + Text("Back to Login") + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextSecondary) + } + .padding(.top, 8) + } + .padding(OrganicSpacing.cozy) + .background(OrganicFormCardBackground()) + .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) + .naturalShadow(.pronounced) + .padding(.horizontal, 16) + + Spacer() } } - .listRowBackground(Color.appBackgroundSecondary) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) - .navigationTitle("Reset Password") .navigationBarTitleDisplayMode(.inline) .onAppear { isEmailFocused = true } - .handleErrors( - error: viewModel.errorMessage, - onRetry: { viewModel.requestPasswordReset() } - ) + } + } +} + +// MARK: - Organic Form Card Background + +private struct OrganicFormCardBackground: View { + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ZStack { + Color.appBackgroundSecondary + + GeometryReader { geo in + OrganicBlobShape(variation: 0) + .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.5) + .offset(x: geo.size.width * 0.45, y: -geo.size.height * 0.1) + .blur(radius: 20) + } + + GrainTexture(opacity: 0.015) } } } diff --git a/iosApp/iosApp/PasswordReset/ResetPasswordView.swift b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift index db7fbc6..7d07257 100644 --- a/iosApp/iosApp/PasswordReset/ResetPasswordView.swift +++ b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift @@ -12,244 +12,6 @@ struct ResetPasswordView: View { case newPassword, confirmPassword } - var body: some View { - NavigationView { - Form { - // Header Section - Section { - VStack(spacing: 12) { - Image(systemName: "lock.rotation") - .font(.system(size: 60)) - .foregroundStyle(Color.appPrimary.gradient) - .padding(.vertical) - - Text("Set New Password") - .font(.title2) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) - - Text("Create a strong password to secure your account") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical) - } - .listRowBackground(Color.clear) - - // Password Requirements - Section { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: viewModel.newPassword.count >= 8 ? "checkmark.circle.fill" : "circle") - .foregroundColor(viewModel.newPassword.count >= 8 ? Color.appPrimary : Color.appTextSecondary) - Text("At least 8 characters") - .font(.caption) - .foregroundColor(Color.appTextPrimary) - } - - HStack(spacing: 8) { - Image(systemName: hasLetter ? "checkmark.circle.fill" : "circle") - .foregroundColor(hasLetter ? Color.appPrimary : Color.appTextSecondary) - Text("Contains letters") - .font(.caption) - .foregroundColor(Color.appTextPrimary) - } - - HStack(spacing: 8) { - Image(systemName: hasNumber ? "checkmark.circle.fill" : "circle") - .foregroundColor(hasNumber ? Color.appPrimary : Color.appTextSecondary) - Text("Contains numbers") - .font(.caption) - .foregroundColor(Color.appTextPrimary) - } - - HStack(spacing: 8) { - Image(systemName: passwordsMatch ? "checkmark.circle.fill" : "circle") - .foregroundColor(passwordsMatch ? Color.appPrimary : Color.appTextSecondary) - Text("Passwords match") - .font(.caption) - .foregroundColor(Color.appTextPrimary) - } - } - } header: { - Text("Password Requirements") - } - - // New Password Input - Section { - HStack { - if isNewPasswordVisible { - TextField("Enter new password", text: $viewModel.newPassword) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused($focusedField, equals: .newPassword) - .submitLabel(.next) - .onSubmit { - focusedField = .confirmPassword - } - } else { - SecureField("Enter new password", text: $viewModel.newPassword) - .focused($focusedField, equals: .newPassword) - .submitLabel(.next) - .onSubmit { - focusedField = .confirmPassword - } - } - - Button(action: { - isNewPasswordVisible.toggle() - }) { - Image(systemName: isNewPasswordVisible ? "eye.slash.fill" : "eye.fill") - .foregroundColor(Color.appTextSecondary) - } - .buttonStyle(.plain) - } - .onChange(of: viewModel.newPassword) { _, _ in - viewModel.clearError() - } - } header: { - Text("New Password") - } - - // Confirm Password Input - Section { - HStack { - if isConfirmPasswordVisible { - TextField("Re-enter new password", text: $viewModel.confirmPassword) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .focused($focusedField, equals: .confirmPassword) - .submitLabel(.go) - .onSubmit { - viewModel.resetPassword() - } - } else { - SecureField("Re-enter new password", text: $viewModel.confirmPassword) - .focused($focusedField, equals: .confirmPassword) - .submitLabel(.go) - .onSubmit { - viewModel.resetPassword() - } - } - - Button(action: { - isConfirmPasswordVisible.toggle() - }) { - Image(systemName: isConfirmPasswordVisible ? "eye.slash.fill" : "eye.fill") - .foregroundColor(Color.appTextSecondary) - } - .buttonStyle(.plain) - } - .onChange(of: viewModel.confirmPassword) { _, _ in - viewModel.clearError() - } - } header: { - Text("Confirm Password") - } - - // Error/Success Messages - if let errorMessage = viewModel.errorMessage { - Section { - Label { - Text(errorMessage) - .foregroundColor(Color.appError) - } icon: { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(Color.appError) - } - } - } - - if let successMessage = viewModel.successMessage { - Section { - Label { - Text(successMessage) - .foregroundColor(Color.appPrimary) - .multilineTextAlignment(.center) - } icon: { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color.appPrimary) - } - } - } - - // Reset Password Button - Section { - Button(action: { - viewModel.resetPassword() - }) { - HStack { - Spacer() - if viewModel.isLoading || viewModel.currentStep == .loggingIn { - ProgressView() - .padding(.trailing, 8) - Text(viewModel.currentStep == .loggingIn ? "Logging in..." : "Resetting...") - .fontWeight(.semibold) - } else { - Label("Reset Password", systemImage: "lock.shield.fill") - .fontWeight(.semibold) - } - Spacer() - } - } - .disabled(!isFormValid || viewModel.isLoading || viewModel.currentStep == .loggingIn) - - // Return to Login Button (shown only if auto-login fails) - if viewModel.currentStep == .success { - Button(action: { - viewModel.reset() - onSuccess() - }) { - HStack { - Spacer() - Text("Return to Login") - .fontWeight(.semibold) - Spacer() - } - } - } - } - } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) - .navigationTitle("Reset Password") - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - // Only show back button if not from deep link and not logging in - if (viewModel.resetToken == nil || viewModel.currentStep != .resetPassword) && viewModel.currentStep != .loggingIn { - Button(action: { - if viewModel.currentStep == .success { - viewModel.reset() - onSuccess() - } else { - viewModel.moveToPreviousStep() - } - }) { - HStack(spacing: 4) { - Image(systemName: viewModel.currentStep == .success ? "xmark" : "chevron.left") - .font(.system(size: 16)) - Text(viewModel.currentStep == .success ? "Close" : "Back") - .font(.subheadline) - } - } - } - } - } - .onAppear { - focusedField = .newPassword - } - .handleErrors( - error: viewModel.errorMessage, - onRetry: { viewModel.resetPassword() } - ) - } - } - // Computed Properties private var hasLetter: Bool { viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil @@ -271,6 +33,348 @@ struct ResetPasswordView: View { 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 { diff --git a/iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift b/iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift index d513b71..0fa67d1 100644 --- a/iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift +++ b/iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift @@ -7,149 +7,205 @@ struct VerifyResetCodeView: View { var body: some View { NavigationView { - Form { - // Header Section - Section { - VStack(spacing: 12) { - Image(systemName: "envelope.badge.fill") - .font(.system(size: 60)) - .foregroundStyle(Color.appPrimary.gradient) - .padding(.vertical) + ZStack { + WarmGradientBackground() - Text("Check Your Email") - .font(.title2) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.spacious) { + Spacer() + .frame(height: OrganicSpacing.comfortable) - Text("We sent a 6-digit code to") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) + // 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) - Text(viewModel.email) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(Color.appTextPrimary) - } - .frame(maxWidth: .infinity) - .padding(.vertical) - } - .listRowBackground(Color.clear) - - // Info Section - Section { - Label { - Text("Code expires in 15 minutes") - .fontWeight(.semibold) - .foregroundColor(Color.appTextPrimary) - } icon: { - Image(systemName: "clock.fill") - .foregroundColor(Color.appAccent) - } - } - .listRowBackground(Color.appBackgroundSecondary) - - // Code Input Section - Section { - TextField("000000", text: $viewModel.code) - .font(.system(size: 32, weight: .semibold, design: .rounded)) - .multilineTextAlignment(.center) - .keyboardType(.numberPad) - .focused($isCodeFocused) - .keyboardDismissToolbar() - .onChange(of: viewModel.code) { _, newValue in - // Limit to 6 digits - if newValue.count > 6 { - viewModel.code = String(newValue.prefix(6)) + Image(systemName: "envelope.badge.fill") + .font(.system(size: 48, weight: .medium)) + .foregroundColor(Color.appPrimary) } - // Only allow numbers - viewModel.code = newValue.filter { $0.isNumber } - viewModel.clearError() - } - } header: { - Text("Verification Code") - } footer: { - Text("Enter the 6-digit code from your email") - } - .listRowBackground(Color.appBackgroundSecondary) - // Error/Success Messages - if let errorMessage = viewModel.errorMessage { - Section { - Label { - Text(errorMessage) - .foregroundColor(Color.appError) - } icon: { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(Color.appError) - } - } - .listRowBackground(Color.appBackgroundSecondary) - } + VStack(spacing: 8) { + Text("Check Your Email") + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) - if let successMessage = viewModel.successMessage { - Section { - Label { - Text(successMessage) - .foregroundColor(Color.appPrimary) - } icon: { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color.appPrimary) - } - } - .listRowBackground(Color.appBackgroundSecondary) - } + Text("We sent a 6-digit code to") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) - // Verify Button - Section { - Button(action: { - viewModel.verifyResetCode() - }) { - HStack { - Spacer() - if viewModel.isLoading { - ProgressView() - } else { - Label("Verify Code", systemImage: "checkmark.shield.fill") - .fontWeight(.semibold) + Text(viewModel.email) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundColor(Color.appPrimary) } - Spacer() - } - } - .disabled(viewModel.code.count != 6 || viewModel.isLoading) - } - .listRowBackground(Color.appBackgroundSecondary) - - // Help Section - Section { - VStack(spacing: 12) { - Text("Didn't receive the code?") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - - Button(action: { - // Clear code and go back to request new one - viewModel.code = "" - viewModel.clearError() - viewModel.currentStep = .requestCode - }) { - Text("Send New Code") - .font(.subheadline) - .fontWeight(.semibold) } - Text("Check your spam folder if you don't see it") - .font(.caption) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) + // Form Card + VStack(spacing: 20) { + // Timer Info + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.appAccent.opacity(0.1)) + .frame(width: 40, height: 40) + Image(systemName: "clock.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(Color.appAccent) + } + + Text("Code expires in 15 minutes") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appTextPrimary) + + Spacer() + } + .padding(16) + .background(Color.appAccent.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + + // Code Input + VStack(alignment: .leading, spacing: 8) { + Text("VERIFICATION CODE") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextSecondary) + .tracking(1.2) + + TextField("000000", text: $viewModel.code) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .multilineTextAlignment(.center) + .keyboardType(.numberPad) + .focused($isCodeFocused) + .keyboardDismissToolbar() + .padding(20) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5) + ) + .onChange(of: viewModel.code) { _, newValue in + if newValue.count > 6 { + viewModel.code = String(newValue.prefix(6)) + } + viewModel.code = newValue.filter { $0.isNumber } + viewModel.clearError() + } + + Text("Enter the 6-digit code from your email") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + + // 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)) + } + + // Verify Button + Button(action: { + viewModel.verifyResetCode() + }) { + HStack(spacing: 8) { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Image(systemName: "checkmark.shield.fill") + } + Text(viewModel.isLoading ? "Verifying..." : "Verify Code") + .font(.headline) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + viewModel.code.count == 6 && !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: viewModel.code.count == 6 && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear, + radius: 10, + y: 5 + ) + } + .disabled(viewModel.code.count != 6 || viewModel.isLoading) + + OrganicDivider() + .padding(.vertical, 4) + + // Help Section + VStack(spacing: 12) { + Text("Didn't receive the code?") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + + Button(action: { + viewModel.code = "" + viewModel.clearError() + viewModel.currentStep = .requestCode + }) { + Text("Send New Code") + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundColor(Color.appPrimary) + } + + Text("Check your spam folder if you don't see it") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + } + .padding(OrganicSpacing.cozy) + .background(OrganicVerifyCardBackground()) + .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) + .naturalShadow(.pronounced) + .padding(.horizontal, 16) + + Spacer() } - .frame(maxWidth: .infinity) } - .listRowBackground(Color.clear) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) - .navigationTitle("Verify Code") .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .toolbar { @@ -157,22 +213,51 @@ struct VerifyResetCodeView: View { Button(action: { viewModel.moveToPreviousStep() }) { - HStack(spacing: 4) { + HStack(spacing: 6) { Image(systemName: "chevron.left") - .font(.system(size: 16)) + .font(.system(size: 14, weight: .semibold)) Text("Back") - .font(.subheadline) + .font(.system(size: 15, weight: .medium)) } + .foregroundColor(Color.appPrimary) } } } .onAppear { isCodeFocused = true } - .handleErrors( - error: viewModel.errorMessage, - onRetry: { viewModel.verifyResetCode() } - ) + } + } +} + +// MARK: - Background + +private struct OrganicVerifyCardBackground: 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.45, y: geo.size.height * 0.5) + .blur(radius: 20) + } + + GrainTexture(opacity: 0.015) } } } diff --git a/iosApp/iosApp/Register/RegisterView.swift b/iosApp/iosApp/Register/RegisterView.swift index 2e10710..99b8228 100644 --- a/iosApp/iosApp/Register/RegisterView.swift +++ b/iosApp/iosApp/Register/RegisterView.swift @@ -6,128 +6,213 @@ struct RegisterView: View { @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 { NavigationView { - Form { - Section { - VStack(spacing: 16) { - Image(systemName: "person.badge.plus") - .font(.system(size: 60)) - .foregroundStyle(Color.appPrimary.gradient) + ZStack { + WarmGradientBackground() - Text(L10n.Auth.joinCasera) - .font(.largeTitle) - .fontWeight(.bold) + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.spacious) { + Spacer() + .frame(height: OrganicSpacing.comfortable) - Text(L10n.Auth.startManaging) - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical) - } - .listRowBackground(Color.clear) + // 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) - Section { - TextField(L10n.Auth.registerUsername, text: $viewModel.username) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .textContentType(.username) - .focused($focusedField, equals: .username) - .submitLabel(.next) - .onSubmit { - focusedField = .email - } - .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField) - - TextField(L10n.Auth.registerEmail, text: $viewModel.email) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.emailAddress) - .textContentType(.emailAddress) - .focused($focusedField, equals: .email) - .submitLabel(.next) - .onSubmit { - focusedField = .password - } - .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField) - } header: { - Text(L10n.Auth.accountInfo) - } - .listRowBackground(Color.appBackgroundSecondary) - - Section { - // Using .newPassword enables iOS Strong Password generation - // iOS will automatically offer to save to iCloud Keychain after successful registration - SecureField(L10n.Auth.registerPassword, text: $viewModel.password) - .textContentType(.newPassword) - .focused($focusedField, equals: .password) - .submitLabel(.next) - .onSubmit { - focusedField = .confirmPassword - } - .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField) - - SecureField(L10n.Auth.registerConfirmPassword, text: $viewModel.confirmPassword) - .textContentType(.newPassword) - .focused($focusedField, equals: .confirmPassword) - .submitLabel(.go) - .onSubmit { - viewModel.register() - } - .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField) - } header: { - Text(L10n.Auth.security) - } footer: { - Text(L10n.Auth.passwordSuggestion) - } - .listRowBackground(Color.appBackgroundSecondary) - - if let errorMessage = viewModel.errorMessage { - Section { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(Color.appError) - Text(errorMessage) - .foregroundColor(Color.appError) - .font(.subheadline) - } - } - .listRowBackground(Color.appBackgroundSecondary) - } - - Section { - Button(action: viewModel.register) { - HStack { - Spacer() - if viewModel.isLoading { - ProgressView() - } else { - Text(L10n.Auth.registerButton) - .fontWeight(.semibold) + 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) } - Spacer() } + + // 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 + ) + .focused($focusedField, equals: .username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textContentType(.username) + .submitLabel(.next) + .onSubmit { focusedField = .email } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField) + + // Email Field + OrganicTextField( + label: nil, + placeholder: L10n.Auth.registerEmail, + text: $viewModel.email, + icon: "envelope.fill", + isFocused: focusedField == .email + ) + .focused($focusedField, equals: .email) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .submitLabel(.next) + .onSubmit { focusedField = .password } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField) + + OrganicDivider() + .padding(.vertical, 4) + + // Password Field + OrganicSecureField( + label: L10n.Auth.security, + placeholder: L10n.Auth.registerPassword, + text: $viewModel.password, + isVisible: $isPasswordVisible, + isFocused: focusedField == .password + ) + .focused($focusedField, equals: .password) + .textContentType(.newPassword) + .submitLabel(.next) + .onSubmit { focusedField = .confirmPassword } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField) + + // Confirm Password Field + OrganicSecureField( + label: nil, + placeholder: L10n.Auth.registerConfirmPassword, + text: $viewModel.confirmPassword, + isVisible: $isConfirmPasswordVisible, + isFocused: focusedField == .confirmPassword + ) + .focused($focusedField, equals: .confirmPassword) + .textContentType(.newPassword) + .submitLabel(.go) + .onSubmit { viewModel.register() } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField) + + 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() } - .disabled(viewModel.isLoading) - .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton) } - .listRowBackground(Color.appBackgroundSecondary) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) - .navigationTitle(L10n.Auth.registerTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button(L10n.Common.cancel) { - dismiss() + 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) } @@ -135,23 +220,16 @@ struct RegisterView: View { .fullScreenCover(isPresented: $viewModel.isRegistered) { VerifyEmailView( onVerifySuccess: { - // User has verified their email - mark as verified - // This will update RootView to show the main app AuthenticationManager.shared.markVerified() showVerifyEmail = false dismiss() }, onLogout: { - // Logout and return to login screen AuthenticationManager.shared.logout() dismiss() } ) } - .handleErrors( - error: viewModel.errorMessage, - onRetry: { viewModel.register() } - ) .onAppear { PostHogAnalytics.shared.screen(AnalyticsEvents.registrationScreenShown) } @@ -159,6 +237,136 @@ struct RegisterView: View { } } +// 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 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)) + } + .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 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) + } else { + SecureField(placeholder, text: $text) + } + } + .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() } diff --git a/iosApp/iosApp/Residence/JoinResidenceView.swift b/iosApp/iosApp/Residence/JoinResidenceView.swift index 86e77c2..9d8433b 100644 --- a/iosApp/iosApp/Residence/JoinResidenceView.swift +++ b/iosApp/iosApp/Residence/JoinResidenceView.swift @@ -7,70 +7,170 @@ struct JoinResidenceView: View { let onJoined: () -> Void @State private var shareCode: String = "" + @FocusState private var isCodeFocused: Bool var body: some View { NavigationView { - Form { - Section { - TextField(L10n.Residences.shareCode, text: $shareCode) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - .onChange(of: shareCode) { newValue in - // Limit to 6 characters and uppercase - if newValue.count > 6 { - shareCode = String(newValue.prefix(6)) - } - shareCode = shareCode.uppercased() - viewModel.clearError() - } - .disabled(viewModel.isLoading) - } header: { - Text(L10n.Residences.enterShareCode) - } footer: { - Text(L10n.Residences.shareCodeFooter) - .foregroundColor(Color.appTextSecondary) - } - .listRowBackground(Color.appBackgroundSecondary) + ZStack { + WarmGradientBackground() - if let error = viewModel.errorMessage { - Section { - Text(error) - .foregroundColor(Color.appError) - } - .listRowBackground(Color.appBackgroundSecondary) - } + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.spacious) { + Spacer() + .frame(height: OrganicSpacing.comfortable) - Section { - Button(action: joinResidence) { - HStack { - Spacer() - if viewModel.isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - } else { - Text(L10n.Residences.joinButton) - .fontWeight(.semibold) + // 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.Residences.joinTitle) + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text(L10n.Residences.enterShareCode) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) } - Spacer() } + + // Form Card + VStack(spacing: 20) { + // Share Code Input + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Residences.shareCode.uppercased()) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextSecondary) + .tracking(1.2) + + TextField("ABC123", text: $shareCode) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .multilineTextAlignment(.center) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .focused($isCodeFocused) + .disabled(viewModel.isLoading) + .padding(20) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5) + ) + .onChange(of: shareCode) { newValue in + if newValue.count > 6 { + shareCode = String(newValue.prefix(6)) + } + shareCode = shareCode.uppercased() + viewModel.clearError() + } + + Text(L10n.Residences.shareCodeFooter) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + + // Error Message + if let error = viewModel.errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(Color.appError) + Text(error) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appError) + Spacer() + } + .padding(16) + .background(Color.appError.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + + // Join Button + Button(action: joinResidence) { + HStack(spacing: 8) { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Image(systemName: "person.badge.plus") + } + Text(viewModel.isLoading ? "Joining..." : L10n.Residences.joinButton) + .font(.headline) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + shareCode.count == 6 && !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: shareCode.count == 6 && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear, + radius: 10, + y: 5 + ) + } + .disabled(shareCode.count != 6 || viewModel.isLoading) + + // Cancel Button + Button(action: { dismiss() }) { + Text(L10n.Common.cancel) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextSecondary) + } + .disabled(viewModel.isLoading) + .padding(.top, 8) + } + .padding(OrganicSpacing.cozy) + .background(OrganicJoinCardBackground()) + .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) + .naturalShadow(.pronounced) + .padding(.horizontal, 16) + + Spacer() } - .disabled(shareCode.count != 6 || viewModel.isLoading) } - .listRowBackground(Color.appBackgroundSecondary) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) - .navigationTitle(L10n.Residences.joinTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button(L10n.Common.cancel) { - dismiss() + 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()) } .disabled(viewModel.isLoading) } } + .onAppear { + isCodeFocused = true + } } } @@ -85,7 +185,38 @@ struct JoinResidenceView: View { onJoined() dismiss() } - // Error is handled by ViewModel and displayed via viewModel.errorMessage + } + } +} + +// MARK: - Background + +private struct OrganicJoinCardBackground: 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.5) + .offset(x: geo.size.width * 0.4, y: geo.size.height * 0.4) + .blur(radius: 20) + } + + GrainTexture(opacity: 0.015) } } } diff --git a/iosApp/iosApp/Residence/ManageUsersView.swift b/iosApp/iosApp/Residence/ManageUsersView.swift index 4cb5267..4ea0ab3 100644 --- a/iosApp/iosApp/Residence/ManageUsersView.swift +++ b/iosApp/iosApp/Residence/ManageUsersView.swift @@ -21,8 +21,7 @@ struct ManageUsersView: View { var body: some View { NavigationView { ZStack { - Color.appBackgroundPrimary - .ignoresSafeArea() + WarmGradientBackground() if isLoading { ProgressView() @@ -71,7 +70,6 @@ struct ManageUsersView: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) .navigationTitle(L10n.Residences.manageUsers) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/iosApp/iosApp/ResidenceFormView.swift b/iosApp/iosApp/ResidenceFormView.swift index 2a23e02..590dce1 100644 --- a/iosApp/iosApp/ResidenceFormView.swift +++ b/iosApp/iosApp/ResidenceFormView.swift @@ -60,172 +60,256 @@ struct ResidenceFormView: View { var body: some View { NavigationView { - Form { - Section { - TextField(L10n.Residences.propertyName, text: $name) - .focused($focusedField, equals: .name) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField) + ZStack { + WarmGradientBackground() - if !nameError.isEmpty { - Text(nameError) - .font(.caption) - .foregroundColor(Color.appError) - } - - Picker(L10n.Residences.propertyType, selection: $selectedPropertyType) { - Text(L10n.Residences.selectType).tag(nil as ResidenceType?) - ForEach(residenceTypes, id: \.id) { type in - Text(type.name).tag(type as ResidenceType?) - } - } - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker) - } header: { - Text(L10n.Residences.propertyDetails) - } footer: { - Text(L10n.Residences.requiredName) - .font(.caption) - .foregroundColor(Color.appError) - } - .listRowBackground(Color.appBackgroundSecondary) - - Section { - TextField(L10n.Residences.streetAddress, text: $streetAddress) - .focused($focusedField, equals: .streetAddress) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField) - - TextField(L10n.Residences.apartmentUnit, text: $apartmentUnit) - .focused($focusedField, equals: .apartmentUnit) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField) - - TextField(L10n.Residences.city, text: $city) - .focused($focusedField, equals: .city) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField) - - TextField(L10n.Residences.stateProvince, text: $stateProvince) - .focused($focusedField, equals: .stateProvince) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField) - - TextField(L10n.Residences.postalCode, text: $postalCode) - .focused($focusedField, equals: .postalCode) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField) - - TextField(L10n.Residences.country, text: $country) - .focused($focusedField, equals: .country) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField) - } header: { - Text(L10n.Residences.address) - } - .listRowBackground(Color.appBackgroundSecondary) - - Section(header: Text(L10n.Residences.propertyFeatures)) { - HStack { - Text(L10n.Residences.bedrooms) - Spacer() - TextField("0", text: $bedrooms) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused($focusedField, equals: .bedrooms) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField) - } - - HStack { - Text(L10n.Residences.bathrooms) - Spacer() - TextField("0.0", text: $bathrooms) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(width: 60) - .focused($focusedField, equals: .bathrooms) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField) - } - - TextField(L10n.Residences.squareFootage, text: $squareFootage) - .keyboardType(.numberPad) - .focused($focusedField, equals: .squareFootage) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField) - - TextField(L10n.Residences.lotSize, text: $lotSize) - .keyboardType(.decimalPad) - .focused($focusedField, equals: .lotSize) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField) - - TextField(L10n.Residences.yearBuilt, text: $yearBuilt) - .keyboardType(.numberPad) - .focused($focusedField, equals: .yearBuilt) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField) - } - .listRowBackground(Color.appBackgroundSecondary) - .keyboardDismissToolbar() - - Section(header: Text(L10n.Residences.additionalDetails)) { - TextField(L10n.Residences.description, text: $description, axis: .vertical) - .lineLimit(3...6) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField) - .keyboardDismissToolbar() - - Toggle(L10n.Residences.primaryResidence, isOn: $isPrimary) - .accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle) - } - .listRowBackground(Color.appBackgroundSecondary) - - // Users section (edit mode only, owner only) - if isEditMode && isCurrentUserOwner { - Section { - if isLoadingUsers { - HStack { - Spacer() - ProgressView() - Spacer() - } - } else if users.isEmpty { - Text("No shared users") - .foregroundColor(.secondary) - } else { - ForEach(users, id: \.id) { user in - UserRow( - user: user, - isOwner: user.id == existingResidence?.ownerId, - onRemove: { - userToRemove = user - showRemoveUserConfirmation = true - } + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.comfortable) { + // Property Details Section + OrganicFormSection(title: L10n.Residences.propertyDetails, icon: "house.fill") { + VStack(spacing: 16) { + OrganicFormTextField( + label: L10n.Residences.propertyName, + placeholder: "My Home", + text: $name, + error: nameError.isEmpty ? nil : nameError ) + .focused($focusedField, equals: .name) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField) + + OrganicFormPicker( + label: L10n.Residences.propertyType, + selection: $selectedPropertyType, + options: residenceTypes, + optionLabel: { $0.name }, + placeholder: L10n.Residences.selectType + ) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker) } } - } header: { - Text("Shared Users (\(users.count))") - } footer: { - Text("Users with access to this residence. Use the share button to invite others.") - } - .listRowBackground(Color.appBackgroundSecondary) - } + .padding(.top, 8) - if let errorMessage = viewModel.errorMessage { - Section { - Text(errorMessage) - .foregroundColor(Color.appError) - .font(.caption) + // Address Section + OrganicFormSection(title: L10n.Residences.address, icon: "mappin.circle.fill") { + VStack(spacing: 16) { + OrganicFormTextField( + label: L10n.Residences.streetAddress, + placeholder: "123 Main St", + text: $streetAddress + ) + .focused($focusedField, equals: .streetAddress) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField) + + OrganicFormTextField( + label: L10n.Residences.apartmentUnit, + placeholder: "Apt 4B", + text: $apartmentUnit + ) + .focused($focusedField, equals: .apartmentUnit) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField) + + HStack(spacing: 12) { + OrganicFormTextField( + label: L10n.Residences.city, + placeholder: "City", + text: $city + ) + .focused($focusedField, equals: .city) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField) + + OrganicFormTextField( + label: L10n.Residences.stateProvince, + placeholder: "State", + text: $stateProvince + ) + .focused($focusedField, equals: .stateProvince) + .frame(maxWidth: 120) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField) + } + + HStack(spacing: 12) { + OrganicFormTextField( + label: L10n.Residences.postalCode, + placeholder: "12345", + text: $postalCode + ) + .focused($focusedField, equals: .postalCode) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField) + + OrganicFormTextField( + label: L10n.Residences.country, + placeholder: "USA", + text: $country + ) + .focused($focusedField, equals: .country) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField) + } + } + } + + // Property Features Section + OrganicFormSection(title: L10n.Residences.propertyFeatures, icon: "square.grid.2x2.fill") { + VStack(spacing: 16) { + HStack(spacing: 12) { + OrganicFormTextField( + label: L10n.Residences.bedrooms, + placeholder: "0", + text: $bedrooms, + keyboardType: .numberPad + ) + .focused($focusedField, equals: .bedrooms) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField) + + OrganicFormTextField( + label: L10n.Residences.bathrooms, + placeholder: "0.0", + text: $bathrooms, + keyboardType: .decimalPad + ) + .focused($focusedField, equals: .bathrooms) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField) + } + + HStack(spacing: 12) { + OrganicFormTextField( + label: L10n.Residences.squareFootage, + placeholder: "sq ft", + text: $squareFootage, + keyboardType: .numberPad + ) + .focused($focusedField, equals: .squareFootage) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField) + + OrganicFormTextField( + label: L10n.Residences.lotSize, + placeholder: "acres", + text: $lotSize, + keyboardType: .decimalPad + ) + .focused($focusedField, equals: .lotSize) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField) + } + + OrganicFormTextField( + label: L10n.Residences.yearBuilt, + placeholder: "2020", + text: $yearBuilt, + keyboardType: .numberPad + ) + .focused($focusedField, equals: .yearBuilt) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField) + } + } + + // Additional Details Section + OrganicFormSection(title: L10n.Residences.additionalDetails, icon: "text.alignleft") { + VStack(spacing: 16) { + OrganicFormTextArea( + label: L10n.Residences.description, + placeholder: "Add notes about your property...", + text: $description + ) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField) + + OrganicFormToggle( + label: L10n.Residences.primaryResidence, + isOn: $isPrimary, + icon: "star.fill" + ) + .accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle) + } + } + + // Users Section (edit mode only, owner only) + if isEditMode && isCurrentUserOwner { + OrganicFormSection(title: "Shared Users (\(users.count))", icon: "person.2.fill") { + VStack(spacing: 12) { + if isLoadingUsers { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 20) + } else if users.isEmpty { + Text("No shared users") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .padding(.vertical, 12) + } else { + ForEach(users, id: \.id) { user in + OrganicUserRow( + user: user, + isOwner: user.id == existingResidence?.ownerId, + onRemove: { + userToRemove = user + showRemoveUserConfirmation = true + } + ) + } + } + + Text("Use the share button to invite others") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + } + } + + // 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)) + .padding(.horizontal, 16) + } + + Spacer() + .frame(height: 40) } - .listRowBackground(Color.appBackgroundSecondary) + .padding(.horizontal, 16) } + .keyboardDismissToolbar() } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) .navigationTitle(isEditMode ? L10n.Residences.editTitle : L10n.Residences.addTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button(L10n.Common.cancel) { - isPresented = false + Button(action: { isPresented = false }) { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appTextSecondary) + .padding(8) + .background(Color.appBackgroundSecondary.opacity(0.8)) + .clipShape(Circle()) } .accessibilityIdentifier(AccessibilityIdentifiers.Residence.formCancelButton) } ToolbarItem(placement: .navigationBarTrailing) { - Button(L10n.Common.save) { - submitForm() + Button(action: submitForm) { + HStack(spacing: 6) { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.appTextOnPrimary)) + .scaleEffect(0.8) + } + Text(L10n.Common.save) + .font(.system(size: 15, weight: .semibold)) + } + .foregroundColor(canSave ? Color.appTextOnPrimary : Color.appTextSecondary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(canSave ? Color.appPrimary : Color.appTextSecondary.opacity(0.3)) + .clipShape(Capsule()) } .disabled(!canSave || viewModel.isLoading) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.saveButton) @@ -255,25 +339,17 @@ struct ResidenceFormView: View { Text("Are you sure you want to remove \(user.username) from this residence?") } } - .handleErrors( - error: viewModel.errorMessage, - onRetry: { submitForm() } - ) } } private func loadResidenceTypes() { Task { - // Trigger residence types refresh if needed - // Residence types are now loaded from DataManagerObservable - // Just trigger a refresh if needed _ = try? await APILayer.shared.getResidenceTypes(forceRefresh: false) } } private func initializeForm() { if let residence = existingResidence { - // Edit mode - populate fields from existing residence name = residence.name streetAddress = residence.streetAddress ?? "" apartmentUnit = residence.apartmentUnit ?? "" @@ -289,12 +365,10 @@ struct ResidenceFormView: View { description = residence.description_ ?? "" isPrimary = residence.isPrimary - // Set the selected property type if let propertyTypeId = residence.propertyTypeId { selectedPropertyType = residenceTypes.first { $0.id == Int32(propertyTypeId) } } } - // In add mode, leave selectedPropertyType as nil to force user to select } private func validateForm() -> Bool { @@ -313,7 +387,6 @@ struct ResidenceFormView: View { private func submitForm() { guard validateForm() else { return } - // Convert optional numeric fields to Kotlin types let bedroomsValue: KotlinInt? = { guard !bedrooms.isEmpty, let value = Int32(bedrooms) else { return nil } return KotlinInt(int: value) @@ -335,7 +408,6 @@ struct ResidenceFormView: View { return KotlinInt(int: value) }() - // Convert propertyType to KotlinInt if it exists let propertyTypeValue: KotlinInt? = { guard let type = selectedPropertyType else { return nil } return KotlinInt(int: Int32(type.id)) @@ -362,7 +434,6 @@ struct ResidenceFormView: View { ) if let residence = existingResidence { - // Edit mode viewModel.updateResidence(id: residence.id, request: request) { success in if success { onSuccess?() @@ -370,10 +441,8 @@ struct ResidenceFormView: View { } } } else { - // Add mode viewModel.createResidence(request: request) { success in if success { - // Track residence created PostHogAnalytics.shared.capture(AnalyticsEvents.residenceCreated, properties: [ "residence_type": selectedPropertyType?.name ?? "unknown" ]) @@ -397,7 +466,6 @@ struct ResidenceFormView: View { await MainActor.run { if let successResult = result as? ApiResultSuccess, let responseData = successResult.data as? [ResidenceUserResponse] { - // Filter out the owner from the list self.users = responseData.filter { $0.id != residence.ownerId } } self.isLoadingUsers = false @@ -433,42 +501,238 @@ struct ResidenceFormView: View { } } -// MARK: - User Row Component +// MARK: - Organic Form Components -private struct UserRow: View { +private struct OrganicFormSection: View { + let title: String + let icon: String + @ViewBuilder let content: Content + @Environment(\.colorScheme) var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 28, height: 28) + Image(systemName: icon) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + + Text(title.uppercased()) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextSecondary) + .tracking(1.2) + } + + content + } + .padding(OrganicSpacing.cozy) + .background( + ZStack { + Color.appBackgroundSecondary + + GeometryReader { geo in + OrganicBlobShape(variation: Int.random(in: 0...2)) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(colorScheme == .dark ? 0.06 : 0.03), + Color.appPrimary.opacity(0.01) + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.4 + ) + ) + .frame(width: geo.size.width * 0.5, height: geo.size.height * 0.5) + .offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.1) + .blur(radius: 15) + } + + GrainTexture(opacity: 0.012) + } + ) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .naturalShadow(.medium) + } +} + +private struct OrganicFormTextField: View { + let label: String + let placeholder: String + @Binding var text: String + var error: String? = nil + var keyboardType: UIKeyboardType = .default + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color.appTextSecondary) + + TextField(placeholder, text: $text) + .font(.system(size: 16, weight: .medium)) + .keyboardType(keyboardType) + .padding(14) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(error != nil ? Color.appError : Color.appTextSecondary.opacity(0.1), lineWidth: 1) + ) + + if let error = error { + Text(error) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color.appError) + } + } + } +} + +private struct OrganicFormTextArea: View { + let label: String + let placeholder: String + @Binding var text: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color.appTextSecondary) + + TextField(placeholder, text: $text, axis: .vertical) + .font(.system(size: 16, weight: .medium)) + .lineLimit(3...6) + .padding(14) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.appTextSecondary.opacity(0.1), lineWidth: 1) + ) + } + } +} + +private struct OrganicFormPicker: View { + let label: String + @Binding var selection: T? + let options: [T] + let optionLabel: (T) -> String + let placeholder: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(label) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color.appTextSecondary) + + Menu { + Button(action: { selection = nil }) { + Text(placeholder) + } + ForEach(options, id: \.self) { option in + Button(action: { selection = option }) { + Text(optionLabel(option)) + } + } + } label: { + HStack { + Text(selection.map { optionLabel($0) } ?? placeholder) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(selection == nil ? Color.appTextSecondary : Color.appTextPrimary) + + Spacer() + + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + .padding(14) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.appTextSecondary.opacity(0.1), lineWidth: 1) + ) + } + } + } +} + +private struct OrganicFormToggle: View { + let label: String + @Binding var isOn: Bool + let icon: String + + var body: some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(isOn ? Color.appAccent.opacity(0.15) : Color.appTextSecondary.opacity(0.1)) + .frame(width: 36, height: 36) + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(isOn ? Color.appAccent : Color.appTextSecondary) + } + + Text(label) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(Color.appTextPrimary) + + Spacer() + + Toggle("", isOn: $isOn) + .labelsHidden() + .tint(Color.appPrimary) + } + .padding(14) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} + +private struct OrganicUserRow: View { let user: ResidenceUserResponse let isOwner: Bool let onRemove: () -> Void var body: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 40, height: 40) + Text(String(user.username.prefix(1)).uppercased()) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { Text(user.username) - .font(.body) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color.appTextPrimary) + if isOwner { Text("Owner") - .font(.caption) - .foregroundColor(.white) + .font(.system(size: 10, weight: .bold)) + .foregroundColor(Color.appTextOnPrimary) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.appPrimary) .clipShape(Capsule()) } } + if !user.email.isEmpty { Text(user.email) - .font(.caption) - .foregroundColor(.secondary) - } - let fullName = [user.firstName, user.lastName] - .compactMap { $0 } - .filter { !$0.isEmpty } - .joined(separator: " ") - if !fullName.isEmpty { - Text(fullName) - .font(.caption) - .foregroundColor(.secondary) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) } } @@ -477,12 +741,18 @@ private struct UserRow: View { if !isOwner { Button(action: onRemove) { Image(systemName: "trash") + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appError) + .padding(8) + .background(Color.appError.opacity(0.1)) + .clipShape(Circle()) } .buttonStyle(.plain) } } - .padding(.vertical, 4) + .padding(12) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } } diff --git a/iosApp/iosApp/Subscription/FeatureComparisonView.swift b/iosApp/iosApp/Subscription/FeatureComparisonView.swift index bddd883..f2c7376 100644 --- a/iosApp/iosApp/Subscription/FeatureComparisonView.swift +++ b/iosApp/iosApp/Subscription/FeatureComparisonView.swift @@ -139,7 +139,7 @@ struct FeatureComparisonView: View { .padding(.bottom, AppSpacing.xl) } } - .background(Color.appBackgroundPrimary) + .background(WarmGradientBackground()) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/iosApp/iosApp/Subscription/UpgradeFeatureView.swift b/iosApp/iosApp/Subscription/UpgradeFeatureView.swift index b14bc85..2390b7a 100644 --- a/iosApp/iosApp/Subscription/UpgradeFeatureView.swift +++ b/iosApp/iosApp/Subscription/UpgradeFeatureView.swift @@ -11,15 +11,14 @@ struct UpgradeFeatureView: View { @State private var selectedProduct: Product? @State private var errorMessage: String? @State private var showSuccessAlert = false + @State private var isAnimating = false @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @StateObject private var storeKit = StoreKitManager.shared - // Look up trigger data from cache private var triggerData: UpgradeTriggerData? { subscriptionCache.upgradeTriggers[triggerKey] } - // Fallback values if trigger not found private var title: String { triggerData?.title ?? "Upgrade Required" } @@ -33,55 +32,90 @@ struct UpgradeFeatureView: View { } var body: some View { - ScrollView { - VStack(spacing: AppSpacing.xl) { - // Icon - Image(systemName: "star.circle.fill") - .font(.system(size: 60)) - .foregroundStyle(Color.appAccent.gradient) - .padding(.top, AppSpacing.xl) + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.comfortable) { + // Hero Section + VStack(spacing: OrganicSpacing.comfortable) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.appAccent.opacity(0.2), + Color.appAccent.opacity(0.05), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 80 + ) + ) + .frame(width: 160, height: 160) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true), + value: isAnimating + ) - // Title - Text(title) - .font(.title2.weight(.bold)) - .foregroundColor(Color.appTextPrimary) - .multilineTextAlignment(.center) - .padding(.horizontal) + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appAccent, Color.appAccent.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 80, height: 80) - // Message - Text(message) - .font(.body) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - // Pro Features Preview - Dynamic content or fallback - Group { - if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty { - PromoContentView(content: promoContent) - .padding() - } else { - // Fallback to static features if no promo content - VStack(alignment: .leading, spacing: AppSpacing.md) { - FeatureRow(icon: "house.fill", text: "Unlimited properties") - FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks") - FeatureRow(icon: "person.2.fill", text: "Contractor management") - FeatureRow(icon: "doc.fill", text: "Document & warranty storage") + Image(systemName: "star.fill") + .font(.system(size: 36, weight: .medium)) + .foregroundColor(.white) } - .padding() + .naturalShadow(.pronounced) + } + .padding(.top, OrganicSpacing.comfortable) + + VStack(spacing: 8) { + Text(title) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + .multilineTextAlignment(.center) + + Text(message) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal) } } - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.lg) - .padding(.horizontal) + + // Features Card + VStack(spacing: 16) { + if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty { + PromoContentView(content: promoContent) + } else { + VStack(alignment: .leading, spacing: 14) { + OrganicUpgradeFeatureRow(icon: "house.fill", text: "Unlimited properties") + OrganicUpgradeFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks") + OrganicUpgradeFeatureRow(icon: "person.2.fill", text: "Contractor management") + OrganicUpgradeFeatureRow(icon: "doc.fill", text: "Document & warranty storage") + } + } + } + .padding(OrganicSpacing.cozy) + .background(OrganicUpgradeCardBackground()) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .naturalShadow(.medium) + .padding(.horizontal, 16) // Subscription Products - if storeKit.isLoading { - ProgressView() - .tint(Color.appPrimary) - .padding() - } else if !storeKit.products.isEmpty { - VStack(spacing: AppSpacing.md) { + VStack(spacing: 12) { + if storeKit.isLoading { + ProgressView() + .tint(Color.appPrimary) + .padding() + } else if !storeKit.products.isEmpty { ForEach(storeKit.products, id: \.id) { product in SubscriptionProductButton( product: product, @@ -93,69 +127,64 @@ struct UpgradeFeatureView: View { } ) } - } - .padding(.horizontal) - } else { - // Fallback upgrade button if products fail to load - Button(action: { - Task { await storeKit.loadProducts() } - }) { - HStack { - if isProcessing { - ProgressView() - .tint(Color.appTextOnPrimary) - } else { + } else { + Button(action: { + Task { await storeKit.loadProducts() } + }) { + HStack(spacing: 8) { + Image(systemName: "arrow.clockwise") Text("Retry Loading Products") - .fontWeight(.semibold) + .font(.system(size: 16, weight: .semibold)) } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background(Color.appPrimary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } - .frame(maxWidth: .infinity) - .foregroundColor(Color.appTextOnPrimary) - .padding() - .background(Color.appPrimary) - .cornerRadius(AppRadius.md) } - .disabled(isProcessing) - .padding(.horizontal) } + .padding(.horizontal, 16) // Error Message if let error = errorMessage { - HStack { - Image(systemName: "exclamationmark.triangle.fill") + HStack(spacing: 10) { + Image(systemName: "exclamationmark.circle.fill") .foregroundColor(Color.appError) Text(error) - .font(.subheadline) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appError) + Spacer() } - .padding() + .padding(16) .background(Color.appError.opacity(0.1)) - .cornerRadius(AppRadius.md) - .padding(.horizontal) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .padding(.horizontal, 16) } - // Compare Plans - Button(action: { - showFeatureComparison = true - }) { - Text("Compare Free vs Pro") - .font(.subheadline) - .foregroundColor(Color.appPrimary) - } + // Links + VStack(spacing: 12) { + Button(action: { + showFeatureComparison = true + }) { + Text("Compare Free vs Pro") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } - // Restore Purchases - Button(action: { - handleRestore() - }) { - Text("Restore Purchases") - .font(.caption) - .foregroundColor(Color.appTextSecondary) + Button(action: { + handleRestore() + }) { + Text("Restore Purchases") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } } - .padding(.bottom, AppSpacing.xxxl) + .padding(.bottom, OrganicSpacing.airy) } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.appBackgroundPrimary) + .background(WarmGradientBackground()) .sheet(isPresented: $showFeatureComparison) { FeatureComparisonView(isPresented: $showFeatureComparison) } @@ -165,10 +194,12 @@ struct UpgradeFeatureView: View { Text("You now have full access to all Pro features!") } .task { - // Refresh subscription cache to get latest upgrade triggers subscriptionCache.refreshFromCache() await storeKit.loadProducts() } + .onAppear { + isAnimating = true + } } private func handlePurchase(_ product: Product) { @@ -183,7 +214,6 @@ struct UpgradeFeatureView: View { isProcessing = false if transaction != nil { - // Purchase successful showSuccessAlert = true } } @@ -216,6 +246,64 @@ struct UpgradeFeatureView: View { } } +// MARK: - Organic Feature Row + +private struct OrganicUpgradeFeatureRow: View { + let icon: String + let text: String + + var body: some View { + HStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 36, height: 36) + Image(systemName: icon) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + + Text(text) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextPrimary) + + Spacer() + } + } +} + +// MARK: - Organic Card Background + +private struct OrganicUpgradeCardBackground: View { + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ZStack { + Color.appBackgroundSecondary + + GeometryReader { geo in + OrganicBlobShape(variation: 2) + .fill( + RadialGradient( + colors: [ + Color.appAccent.opacity(colorScheme == .dark ? 0.08 : 0.05), + Color.appAccent.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.6) + .offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.4) + .blur(radius: 20) + } + + GrainTexture(opacity: 0.015) + } + } +} + #Preview { UpgradeFeatureView( triggerKey: "view_contractors", diff --git a/iosApp/iosApp/Subscription/UpgradePromptView.swift b/iosApp/iosApp/Subscription/UpgradePromptView.swift index 58fa4c2..c188a16 100644 --- a/iosApp/iosApp/Subscription/UpgradePromptView.swift +++ b/iosApp/iosApp/Subscription/UpgradePromptView.swift @@ -21,30 +21,35 @@ struct PromoContentView: View { case .title(let text): Text(text) - .font(.title3.bold()) + .font(.system(size: 18, weight: .bold, design: .rounded)) .foregroundColor(Color.appPrimary) .multilineTextAlignment(.center) case .body(let text): Text(text) - .font(.subheadline) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appTextSecondary) .multilineTextAlignment(.center) case .checkItem(let text): - HStack(alignment: .top, spacing: 8) { - Image(systemName: "checkmark") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(Color.appPrimary) + HStack(alignment: .top, spacing: 10) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 24, height: 24) + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(Color.appPrimary) + } Text(text) - .font(.subheadline) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appTextPrimary) Spacer() } case .italic(let text): Text(text) - .font(.caption) + .font(.system(size: 12, weight: .medium)) .italic() .foregroundColor(Color.appAccent) .multilineTextAlignment(.center) @@ -78,15 +83,12 @@ struct PromoContentView: View { let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) result.append(.checkItem(text)) } else if trimmed.contains("") && trimmed.contains("") { - // Title line with emoji let cleaned = trimmed .replacingOccurrences(of: "", with: "") .replacingOccurrences(of: "", with: "") - // Check if starts with emoji if let firstScalar = cleaned.unicodeScalars.first, firstScalar.properties.isEmoji && !firstScalar.properties.isASCIIHexDigit { - // Split emoji and title let parts = cleaned.split(separator: " ", maxSplits: 1) if parts.count == 2 { result.append(.emoji(String(parts[0]))) @@ -104,7 +106,6 @@ struct PromoContentView: View { result.append(.italic(text)) } else if trimmed.first?.unicodeScalars.first?.properties.isEmoji == true && trimmed.count <= 2 { - // Standalone emoji result.append(.emoji(trimmed)) } else { result.append(.body(trimmed)) @@ -126,6 +127,7 @@ struct UpgradePromptView: View { @State private var selectedProduct: Product? @State private var errorMessage: String? @State private var showSuccessAlert = false + @State private var isAnimating = false var triggerData: UpgradeTriggerData? { subscriptionCache.upgradeTriggers[triggerKey] @@ -133,133 +135,171 @@ struct UpgradePromptView: View { var body: some View { NavigationStack { - ScrollView { - VStack(spacing: AppSpacing.xl) { - // Icon - Image(systemName: "star.circle.fill") - .font(.system(size: 60)) - .foregroundStyle(Color.appAccent.gradient) - .padding(.top, AppSpacing.xl) + ZStack { + WarmGradientBackground() - // Title - Text(triggerData?.title ?? "Upgrade to Pro") - .font(.title2.weight(.bold)) - .foregroundColor(Color.appTextPrimary) - .multilineTextAlignment(.center) - .padding(.horizontal) + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.comfortable) { + // Hero Section + VStack(spacing: OrganicSpacing.comfortable) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.appAccent.opacity(0.2), + Color.appAccent.opacity(0.05), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 80 + ) + ) + .frame(width: 160, height: 160) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true), + value: isAnimating + ) - // Message - Text(triggerData?.message ?? "Unlock unlimited access to all features") - .font(.body) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal) + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.appAccent, Color.appAccent.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 80, height: 80) - // Pro Features Preview - Dynamic content or fallback - Group { - if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty { - PromoContentView(content: promoContent) - .padding() - } else { - // Fallback to static features if no promo content - VStack(alignment: .leading, spacing: AppSpacing.md) { - FeatureRow(icon: "house.fill", text: "Unlimited properties") - FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks") - FeatureRow(icon: "person.2.fill", text: "Contractor management") - FeatureRow(icon: "doc.fill", text: "Document & warranty storage") + Image(systemName: "star.fill") + .font(.system(size: 36, weight: .medium)) + .foregroundColor(.white) + } + .naturalShadow(.pronounced) } - .padding() - } - } - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.lg) - .padding(.horizontal) + .padding(.top, OrganicSpacing.comfortable) - // Subscription Products - if storeKit.isLoading { - ProgressView() - .tint(Color.appPrimary) - .padding() - } else if !storeKit.products.isEmpty { - VStack(spacing: AppSpacing.md) { - ForEach(storeKit.products, id: \.id) { product in - SubscriptionProductButton( - product: product, - isSelected: selectedProduct?.id == product.id, - isProcessing: isProcessing, - onSelect: { - selectedProduct = product - handlePurchase(product) - } - ) + VStack(spacing: 8) { + Text(triggerData?.title ?? "Upgrade to Pro") + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + .multilineTextAlignment(.center) + + Text(triggerData?.message ?? "Unlock unlimited access to all features") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal) } } - .padding(.horizontal) - } else { - // Fallback upgrade button if products fail to load - Button(action: { - Task { await storeKit.loadProducts() } - }) { - HStack { - if isProcessing { - ProgressView() - .tint(Color.appTextOnPrimary) - } else { - Text("Retry Loading Products") - .fontWeight(.semibold) + + // Features Card + VStack(spacing: 16) { + if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty { + PromoContentView(content: promoContent) + } else { + VStack(alignment: .leading, spacing: 14) { + OrganicFeatureRow(icon: "house.fill", text: "Unlimited properties") + OrganicFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks") + OrganicFeatureRow(icon: "person.2.fill", text: "Contractor management") + OrganicFeatureRow(icon: "doc.fill", text: "Document & warranty storage") } } - .frame(maxWidth: .infinity) - .foregroundColor(Color.appTextOnPrimary) - .padding() - .background(Color.appPrimary) - .cornerRadius(AppRadius.md) } - .disabled(isProcessing) - .padding(.horizontal) - } + .padding(OrganicSpacing.cozy) + .background(OrganicCardBackground()) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .naturalShadow(.medium) + .padding(.horizontal, 16) - // Error Message - if let error = errorMessage { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(Color.appError) - Text(error) - .font(.subheadline) - .foregroundColor(Color.appError) + // Subscription Products + VStack(spacing: 12) { + if storeKit.isLoading { + ProgressView() + .tint(Color.appPrimary) + .padding() + } else if !storeKit.products.isEmpty { + ForEach(storeKit.products, id: \.id) { product in + OrganicSubscriptionButton( + product: product, + isSelected: selectedProduct?.id == product.id, + isProcessing: isProcessing, + onSelect: { + selectedProduct = product + handlePurchase(product) + } + ) + } + } else { + Button(action: { + Task { await storeKit.loadProducts() } + }) { + HStack(spacing: 8) { + Image(systemName: "arrow.clockwise") + Text("Retry Loading Products") + .font(.system(size: 16, weight: .semibold)) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background(Color.appPrimary) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + } } - .padding() - .background(Color.appError.opacity(0.1)) - .cornerRadius(AppRadius.md) - .padding(.horizontal) - } + .padding(.horizontal, 16) - // Compare Plans - Button(action: { - showFeatureComparison = true - }) { - Text("Compare Free vs Pro") - .font(.subheadline) - .foregroundColor(Color.appPrimary) - } + // Error Message + if let error = errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(Color.appError) + Text(error) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appError) + Spacer() + } + .padding(16) + .background(Color.appError.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .padding(.horizontal, 16) + } - // Restore Purchases - Button(action: { - handleRestore() - }) { - Text("Restore Purchases") - .font(.caption) - .foregroundColor(Color.appTextSecondary) + // Links + VStack(spacing: 12) { + Button(action: { + showFeatureComparison = true + }) { + Text("Compare Free vs Pro") + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + + Button(action: { + handleRestore() + }) { + Text("Restore Purchases") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + } + .padding(.bottom, OrganicSpacing.airy) } - .padding(.bottom, AppSpacing.xl) } } - .background(Color.appBackgroundPrimary) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - isPresented = false + Button(action: { isPresented = false }) { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appTextSecondary) + .padding(8) + .background(Color.appBackgroundSecondary.opacity(0.8)) + .clipShape(Circle()) } } } @@ -274,10 +314,12 @@ struct UpgradePromptView: View { Text("You now have full access to all Pro features!") } .task { - // Refresh subscription cache to get latest upgrade triggers subscriptionCache.refreshFromCache() await storeKit.loadProducts() } + .onAppear { + isAnimating = true + } } } @@ -293,7 +335,6 @@ struct UpgradePromptView: View { isProcessing = false if transaction != nil { - // Purchase successful showSuccessAlert = true } } @@ -326,6 +367,144 @@ struct UpgradePromptView: View { } } +// MARK: - Organic Feature Row + +private struct OrganicFeatureRow: View { + let icon: String + let text: String + + var body: some View { + HStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 36, height: 36) + Image(systemName: icon) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + + Text(text) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextPrimary) + + Spacer() + } + } +} + +// MARK: - Organic Subscription Button + +private struct OrganicSubscriptionButton: View { + let product: Product + let isSelected: Bool + let isProcessing: Bool + let onSelect: () -> Void + @Environment(\.colorScheme) var colorScheme + + var isAnnual: Bool { + product.id.contains("annual") + } + + var savingsText: String? { + if isAnnual { + return "Save 17%" + } + return nil + } + + var body: some View { + Button(action: onSelect) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(product.displayName) + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(isAnnual ? Color.appTextOnPrimary : Color.appTextPrimary) + + if let savings = savingsText { + Text(savings) + .font(.system(size: 12, weight: .bold)) + .foregroundColor(isAnnual ? Color.white.opacity(0.9) : Color.appPrimary) + } + } + + Spacer() + + if isProcessing && isSelected { + ProgressView() + .tint(isAnnual ? .white : Color.appPrimary) + } else { + Text(product.displayPrice) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .foregroundColor(isAnnual ? Color.appTextOnPrimary : Color.appPrimary) + } + } + .padding(18) + .frame(maxWidth: .infinity) + .background( + ZStack { + if isAnnual { + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } else { + Color.appBackgroundSecondary + } + + if !isAnnual { + GrainTexture(opacity: 0.01) + } + } + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(isAnnual ? Color.appAccent : Color.appTextSecondary.opacity(0.15), lineWidth: isAnnual ? 2 : 1) + ) + .shadow( + color: isAnnual ? Color.appPrimary.opacity(0.3) : Color.black.opacity(colorScheme == .dark ? 0.3 : 0.08), + radius: isAnnual ? 12 : 8, + y: isAnnual ? 6 : 4 + ) + } + .disabled(isProcessing) + } +} + +// MARK: - Organic Card Background + +private struct OrganicCardBackground: 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.5) + .offset(x: geo.size.width * 0.4, y: -geo.size.height * 0.1) + .blur(radius: 20) + } + + GrainTexture(opacity: 0.015) + } + } +} + struct SubscriptionProductButton: View { let product: Product let isSelected: Bool @@ -344,60 +523,21 @@ struct SubscriptionProductButton: View { } var body: some View { - Button(action: onSelect) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(product.displayName) - .font(.headline) - .foregroundColor(Color.appTextPrimary) - - if let savings = savingsText { - Text(savings) - .font(.caption) - .foregroundColor(Color.appPrimary) - } - } - - Spacer() - - if isProcessing && isSelected { - ProgressView() - .tint(Color.appTextOnPrimary) - } else { - Text(product.displayPrice) - .font(.title3.weight(.bold)) - .foregroundColor(Color.appTextOnPrimary) - } - } - .padding() - .frame(maxWidth: .infinity) - .background(isAnnual ? Color.appPrimary : Color.appSecondary) - .cornerRadius(AppRadius.md) - .overlay( - RoundedRectangle(cornerRadius: AppRadius.md) - .stroke(isAnnual ? Color.appAccent : Color.clear, lineWidth: 2) - ) - } - .disabled(isProcessing) + OrganicSubscriptionButton( + product: product, + isSelected: isSelected, + isProcessing: isProcessing, + onSelect: onSelect + ) } } struct FeatureRow: View { let icon: String let text: String - + var body: some View { - HStack(spacing: AppSpacing.md) { - Image(systemName: icon) - .foregroundColor(Color.appPrimary) - .frame(width: 24) - - Text(text) - .font(.body) - .foregroundColor(Color.appTextPrimary) - - Spacer() - } + OrganicFeatureRow(icon: icon, text: text) } } diff --git a/iosApp/iosApp/Subviews/Common/ErrorView.swift b/iosApp/iosApp/Subviews/Common/ErrorView.swift index d0c47ed..7682123 100644 --- a/iosApp/iosApp/Subviews/Common/ErrorView.swift +++ b/iosApp/iosApp/Subviews/Common/ErrorView.swift @@ -5,25 +5,34 @@ struct ErrorView: View { let retryAction: () -> Void var body: some View { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 64)) - .foregroundColor(Color.appError) + VStack(spacing: OrganicSpacing.cozy) { + ZStack { + Circle() + .fill(Color.appError.opacity(0.1)) + .frame(width: 100, height: 100) + + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 44, weight: .medium)) + .foregroundColor(Color.appError) + } Text("Error: \(message)") - .foregroundColor(Color.appError) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) .multilineTextAlignment(.center) Button(action: retryAction) { Text("Retry") + .font(.system(size: 16, weight: .semibold)) .padding(.horizontal, 32) - .padding(.vertical, 12) + .padding(.vertical, 14) .background(Color.appPrimary) .foregroundColor(Color.appTextOnPrimary) - .cornerRadius(8) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .naturalShadow(.subtle) } } - .padding() + .padding(OrganicSpacing.comfortable) } } diff --git a/iosApp/iosApp/Subviews/Common/StatView.swift b/iosApp/iosApp/Subviews/Common/StatView.swift index ff55df3..b69d202 100644 --- a/iosApp/iosApp/Subviews/Common/StatView.swift +++ b/iosApp/iosApp/Subviews/Common/StatView.swift @@ -7,23 +7,23 @@ struct StatView: View { var color: Color = Color.appPrimary var body: some View { - VStack(spacing: AppSpacing.sm) { + VStack(spacing: OrganicSpacing.compact) { ZStack { Circle() .fill(color.opacity(0.1)) - .frame(width: 48, height: 48) + .frame(width: 52, height: 52) if icon == "house_outline" { Image("house_outline") .resizable() - .frame(width: 22, height: 22) + .frame(width: 24, height: 24) .foregroundColor(Color.appTextOnPrimary) .background(content: { - RoundedRectangle(cornerRadius: AppRadius.sm) + RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) - .frame(width: 22, height: 22) - .shadow(color: Color.appPrimary.opacity(0.3), radius: 6, y: 3) + .frame(width: 24, height: 24) }) + .naturalShadow(.subtle) } else { Image(systemName: icon) .font(.system(size: 22, weight: .semibold)) @@ -32,12 +32,11 @@ struct StatView: View { } Text(value) - .font(.title2.weight(.semibold)) - .fontWeight(.bold) + .font(.system(size: 22, weight: .bold, design: .rounded)) .foregroundColor(Color.appTextPrimary) Text(label) - .font(.footnote.weight(.medium)) + .font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary) .multilineTextAlignment(.center) } diff --git a/iosApp/iosApp/Subviews/Residence/EmptyResidencesView.swift b/iosApp/iosApp/Subviews/Residence/EmptyResidencesView.swift index dbad3e4..85fb47c 100644 --- a/iosApp/iosApp/Subviews/Residence/EmptyResidencesView.swift +++ b/iosApp/iosApp/Subviews/Residence/EmptyResidencesView.swift @@ -2,18 +2,23 @@ import SwiftUI struct EmptyResidencesView: View { var body: some View { - VStack(spacing: 16) { - Image(systemName: "house") - .font(.system(size: 80)) - .foregroundColor(Color.appPrimary.opacity(0.6)) + VStack(spacing: OrganicSpacing.cozy) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.08)) + .frame(width: 120, height: 120) + + Image(systemName: "house") + .font(.system(size: 56, weight: .medium)) + .foregroundColor(Color.appPrimary.opacity(0.6)) + } Text("No properties yet") - .font(.title2) - .fontWeight(.semibold) + .font(.system(size: 20, weight: .semibold, design: .rounded)) .foregroundColor(Color.appTextPrimary) Text("Add your first property to get started!") - .font(.body) + .font(.system(size: 15, weight: .medium)) .foregroundColor(Color.appTextSecondary) } } diff --git a/iosApp/iosApp/Subviews/Residence/SummaryStatView.swift b/iosApp/iosApp/Subviews/Residence/SummaryStatView.swift index ef9a50c..fe936e4 100644 --- a/iosApp/iosApp/Subviews/Residence/SummaryStatView.swift +++ b/iosApp/iosApp/Subviews/Residence/SummaryStatView.swift @@ -6,18 +6,23 @@ struct SummaryStatView: View { let label: String var body: some View { - VStack(spacing: 8) { - Image(systemName: icon) - .font(.title3) - .foregroundColor(Color.appPrimary) + VStack(spacing: OrganicSpacing.compact) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 44, height: 44) + + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } Text(value) - .font(.title2) - .fontWeight(.bold) + .font(.system(size: 20, weight: .bold, design: .rounded)) .foregroundColor(Color.appTextPrimary) Text(label) - .font(.caption) + .font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary) .multilineTextAlignment(.center) } diff --git a/iosApp/iosApp/Subviews/Task/CompletionCardView.swift b/iosApp/iosApp/Subviews/Task/CompletionCardView.swift index 3c8c452..73ced5c 100644 --- a/iosApp/iosApp/Subviews/Task/CompletionCardView.swift +++ b/iosApp/iosApp/Subviews/Task/CompletionCardView.swift @@ -27,7 +27,7 @@ struct CompletionCardView: View { .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.appAccent.opacity(0.1)) - .cornerRadius(6) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } } @@ -88,13 +88,13 @@ struct CompletionCardView: View { .padding(.vertical, 8) .background(Color.appPrimary.opacity(0.1)) .foregroundColor(Color.appPrimary) - .cornerRadius(8) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } } } - .padding(12) + .padding(14) .background(Color.appBackgroundSecondary.opacity(0.5)) - .cornerRadius(8) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .sheet(isPresented: $showPhotoSheet) { PhotoViewerSheet(images: completion.images) } diff --git a/iosApp/iosApp/Subviews/Task/DynamicTaskColumnView.swift b/iosApp/iosApp/Subviews/Task/DynamicTaskColumnView.swift index ec8ea29..9eee62f 100644 --- a/iosApp/iosApp/Subviews/Task/DynamicTaskColumnView.swift +++ b/iosApp/iosApp/Subviews/Task/DynamicTaskColumnView.swift @@ -38,13 +38,12 @@ struct DynamicTaskColumnView: View { Spacer() Text("\(column.count)") - .font(.caption) - .fontWeight(.semibold) + .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color.appTextOnPrimary) - .padding(.horizontal, 8) - .padding(.vertical, 4) + .padding(.horizontal, 10) + .padding(.vertical, 5) .background(columnColor) - .cornerRadius(12) + .clipShape(Capsule()) } if column.tasks.isEmpty { diff --git a/iosApp/iosApp/Subviews/Task/EmptyTasksView.swift b/iosApp/iosApp/Subviews/Task/EmptyTasksView.swift index 8d1b5cc..1c32104 100644 --- a/iosApp/iosApp/Subviews/Task/EmptyTasksView.swift +++ b/iosApp/iosApp/Subviews/Task/EmptyTasksView.swift @@ -2,19 +2,26 @@ import SwiftUI struct EmptyTasksView: View { var body: some View { - VStack(spacing: 12) { - Image(systemName: "checkmark.circle") - .font(.system(size: 48)) - .foregroundColor(Color.appTextSecondary.opacity(0.5)) + VStack(spacing: OrganicSpacing.cozy) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.08)) + .frame(width: 80, height: 80) + + Image(systemName: "checkmark.circle") + .font(.system(size: 36, weight: .medium)) + .foregroundColor(Color.appPrimary.opacity(0.5)) + } Text("No tasks yet") - .font(.subheadline) + .font(.system(size: 15, weight: .medium)) .foregroundColor(Color.appTextSecondary) } .frame(maxWidth: .infinity) - .padding(32) + .padding(OrganicSpacing.spacious) .background(Color.appBackgroundSecondary) - .cornerRadius(12) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .naturalShadow(.subtle) } } diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 9792c09..9e27fc7 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -18,12 +18,9 @@ struct AllTasksView: View { @State private var selectedTaskForCancel: TaskResponse? @State private var showCancelConfirmation = false - // Deep link task ID to open (from push notification) @State private var pendingTaskId: Int32? - // Column index to scroll to (for deep link navigation) @State private var scrollToColumnIndex: Int? - // Use ViewModel's computed properties private var totalTaskCount: Int { taskViewModel.totalTaskCount } private var hasNoTasks: Bool { taskViewModel.hasNoTasks } private var hasTasks: Bool { taskViewModel.hasTasks } @@ -109,12 +106,10 @@ struct AllTasksView: View { .onAppear { PostHogAnalytics.shared.screen(AnalyticsEvents.taskScreenShown) - // Check for pending navigation from push notification (app launched from notification) if let taskId = PushNotificationManager.shared.pendingNavigationTaskId { pendingTaskId = Int32(taskId) } - // Check if widget completed a task - force refresh if dirty if WidgetDataManager.shared.areTasksDirty() { WidgetDataManager.shared.clearDirtyFlag() loadAllTasks(forceRefresh: true) @@ -123,43 +118,29 @@ struct AllTasksView: View { } residenceViewModel.loadMyResidences() } - // Handle push notification deep links .onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in - print("📬 AllTasksView received .navigateToTask notification") if let userInfo = notification.userInfo, let taskId = userInfo["taskId"] as? Int { - print("📬 Setting pendingTaskId to \(taskId)") pendingTaskId = Int32(taskId) - // If tasks are already loaded, try to navigate immediately if let response = tasksResponse { - print("📬 Tasks already loaded, attempting immediate navigation") navigateToTaskInKanban(taskId: Int32(taskId), response: response) } - } else { - print("📬 Failed to extract taskId from notification userInfo: \(notification.userInfo ?? [:])") } } .onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { notification in - print("📬 AllTasksView received .navigateToEditTask notification") if let userInfo = notification.userInfo, let taskId = userInfo["taskId"] as? Int { - print("📬 Setting pendingTaskId to \(taskId)") pendingTaskId = Int32(taskId) - // If tasks are already loaded, try to navigate immediately if let response = tasksResponse { - print("📬 Tasks already loaded, attempting immediate navigation") navigateToTaskInKanban(taskId: Int32(taskId), response: response) } } } - // When tasks load and we have a pending task ID, scroll to column and open the edit sheet .onChange(of: tasksResponse) { response in - print("📬 tasksResponse changed, pendingTaskId=\(pendingTaskId?.description ?? "nil")") if let taskId = pendingTaskId, let response = response { navigateToTaskInKanban(taskId: taskId, response: response) } } - // Check dirty flag when app returns from background (widget may have completed a task) .onChange(of: scenePhase) { newPhase in if newPhase == .active { if WidgetDataManager.shared.areTasksDirty() { @@ -173,9 +154,8 @@ struct AllTasksView: View { @ViewBuilder private var mainContent: some View { ZStack { - Color.appBackgroundPrimary - .ignoresSafeArea() - + WarmGradientBackground() + if hasNoTasks && isLoadingTasks { ProgressView() } else if let error = tasksError { @@ -184,55 +164,13 @@ struct AllTasksView: View { } } else if let tasksResponse = tasksResponse { if hasNoTasks { - // Empty state with big button - VStack(spacing: 24) { - Spacer() - - Image(systemName: "checklist") - .font(.system(size: 64)) - .foregroundStyle(Color.appPrimary.opacity(0.6)) - - Text(L10n.Tasks.noTasksYet) - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(Color.appTextPrimary) - - Text(L10n.Tasks.createFirst) - .font(.body) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - - Button(action: { - // Check if we should show upgrade prompt before adding - if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") { - showingUpgradePrompt = true - } else { - showAddTask = true - } - }) { - HStack(spacing: 8) { - Image(systemName: "plus") - Text(L10n.Tasks.addButton) - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .frame(height: 50) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .padding(.horizontal, 48) - .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true) - .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) - - if residenceViewModel.myResidences?.residences.isEmpty ?? true { - Text(L10n.Tasks.addPropertyFirst) - .font(.caption) - .foregroundColor(Color.appError) - } - - Spacer() - } - .padding() + OrganicEmptyTasksView( + totalTaskCount: totalTaskCount, + hasResidences: !(residenceViewModel.myResidences?.residences.isEmpty ?? true), + subscriptionCache: subscriptionCache, + showingUpgradePrompt: $showingUpgradePrompt, + showAddTask: $showAddTask + ) } else { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { @@ -277,7 +215,6 @@ struct AllTasksView: View { } ) - // Show swipe hint on first column when it's empty but others have tasks if index == 0 && shouldShowSwipeHint { SwipeHintView() } @@ -300,7 +237,6 @@ struct AllTasksView: View { withAnimation(.easeInOut(duration: 0.3)) { proxy.scrollTo(columnIndex, anchor: .leading) } - // Clear after scrolling DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { scrollToColumnIndex = nil } @@ -310,35 +246,33 @@ struct AllTasksView: View { } } } - .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) - .navigationTitle(L10n.Tasks.allTasks) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { - // Check if we should show upgrade prompt before adding - if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") { - showingUpgradePrompt = true - } else { - showAddTask = true + HStack(spacing: 12) { + Button(action: { + loadAllTasks(forceRefresh: true) + }) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appTextSecondary) + .rotationEffect(.degrees(isLoadingTasks ? 360 : 0)) + .animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks) } - }) { - Image(systemName: "plus") + .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true || isLoadingTasks) + + Button(action: { + if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") { + showingUpgradePrompt = true + } else { + showAddTask = true + } + }) { + OrganicToolbarAddButton() + } + .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true) + .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) } - .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true) - .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) - } - - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { - loadAllTasks(forceRefresh: true) - }) { - Image(systemName: "arrow.clockwise") - .rotationEffect(.degrees(isLoadingTasks ? 360 : 0)) - .animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks) - } - .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true || isLoadingTasks) } } .onChange(of: taskViewModel.isLoading) { isLoading in @@ -347,7 +281,7 @@ struct AllTasksView: View { } } } - + private func loadAllTasks(forceRefresh: Bool = false) { taskViewModel.loadTasks(forceRefresh: forceRefresh) } @@ -357,33 +291,157 @@ struct AllTasksView: View { } private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) { - print("📬 navigateToTaskInKanban called with taskId=\(taskId)") - - // Find which column contains the task for (index, column) in response.columns.enumerated() { if column.tasks.contains(where: { $0.id == taskId }) { - print("📬 Found task in column \(index) '\(column.name)'") - - // Clear pending pendingTaskId = nil PushNotificationManager.shared.clearPendingNavigation() - - // Small delay to ensure view is ready, then scroll DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.scrollToColumnIndex = index } return } } - - // Task not found - print("📬 Task with id=\(taskId) not found") pendingTaskId = nil PushNotificationManager.shared.clearPendingNavigation() } } -// Extension to apply corner radius to specific corners +// MARK: - Organic Empty Tasks View + +private struct OrganicEmptyTasksView: View { + let totalTaskCount: Int + let hasResidences: Bool + let subscriptionCache: SubscriptionCacheWrapper + @Binding var showingUpgradePrompt: Bool + @Binding var showAddTask: Bool + @State private var isAnimating = false + + var body: some View { + VStack(spacing: OrganicSpacing.comfortable) { + Spacer() + + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.15), + Color.appPrimary.opacity(0.05), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 80 + ) + ) + .frame(width: 160, height: 160) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + Animation.easeInOut(duration: 3).repeatForever(autoreverses: true), + value: isAnimating + ) + + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 100, height: 100) + + Image(systemName: "checklist") + .font(.system(size: 44, weight: .medium)) + .foregroundColor(Color.appPrimary) + .offset(y: isAnimating ? -2 : 2) + .animation( + Animation.easeInOut(duration: 2).repeatForever(autoreverses: true), + value: isAnimating + ) + } + } + + VStack(spacing: 12) { + Text(L10n.Tasks.noTasksYet) + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text(L10n.Tasks.createFirst) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + .padding(.top, 8) + + Button(action: { + if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") { + showingUpgradePrompt = true + } else { + showAddTask = true + } + }) { + HStack(spacing: 10) { + Image(systemName: "plus") + .font(.system(size: 16, weight: .bold)) + Text(L10n.Tasks.addButton) + .font(.system(size: 17, weight: .semibold)) + } + .foregroundColor(Color.appTextOnPrimary) + .frame(maxWidth: .infinity) + .frame(height: 56) + .background( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(color: Color.appPrimary.opacity(0.3), radius: 12, y: 6) + } + .disabled(!hasResidences) + .padding(.horizontal, 48) + .padding(.top, 16) + .accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) + + if !hasResidences { + Text(L10n.Tasks.addPropertyFirst) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.appError) + } + + Spacer() + + HStack(spacing: 40) { + FloatingLeaf(delay: 0, size: 18, color: Color.appPrimary) + FloatingLeaf(delay: 0.5, size: 14, color: Color.appAccent) + FloatingLeaf(delay: 1.0, size: 20, color: Color.appPrimary) + } + .opacity(0.6) + .padding(.bottom, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + isAnimating = true + } + } +} + +// MARK: - Organic Toolbar Add Button + +private struct OrganicToolbarAddButton: View { + var body: some View { + ZStack { + Circle() + .fill(Color.appPrimary) + .frame(width: 32, height: 32) + + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(Color.appTextOnPrimary) + } + } +} + +// MARK: - Extensions + extension View { func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { clipShape(RoundedCorner(radius: radius, corners: corners)) @@ -393,7 +451,7 @@ extension View { struct RoundedCorner: Shape { var radius: CGFloat = .infinity var corners: UIRectCorner = .allCorners - + func path(in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, @@ -411,7 +469,6 @@ struct RoundedCorner: Shape { } extension Array where Element == ResidenceResponse { - /// Returns the array as-is (for API compatibility) func toResidences() -> [ResidenceResponse] { return self } diff --git a/iosApp/iosApp/Task/CompleteTaskView.swift b/iosApp/iosApp/Task/CompleteTaskView.swift index 5a2d3aa..07152b3 100644 --- a/iosApp/iosApp/Task/CompleteTaskView.swift +++ b/iosApp/iosApp/Task/CompleteTaskView.swift @@ -259,7 +259,7 @@ struct CompleteTaskView: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) + .background(WarmGradientBackground()) .navigationTitle(L10n.Tasks.completeTask) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -389,30 +389,34 @@ struct ContractorPickerView: View { HStack { VStack(alignment: .leading) { Text(L10n.Tasks.noneManual) - .foregroundStyle(.primary) + .foregroundColor(Color.appTextPrimary) Text(L10n.Tasks.enterManually) .font(.caption) - .foregroundStyle(.secondary) + .foregroundColor(Color.appTextSecondary) } Spacer() if selectedContractor == nil { Image(systemName: "checkmark") - .foregroundStyle(Color.appPrimary) + .foregroundColor(Color.appPrimary) } } } + .listRowBackground(Color.appBackgroundSecondary) // Contractors list if contractorViewModel.isLoading { HStack { Spacer() ProgressView() + .tint(Color.appPrimary) Spacer() } + .listRowBackground(Color.appBackgroundSecondary) } else if let errorMessage = contractorViewModel.errorMessage { Text(errorMessage) - .foregroundStyle(Color.appError) + .foregroundColor(Color.appError) .font(.caption) + .listRowBackground(Color.appBackgroundSecondary) } else { ForEach(contractorViewModel.contractors, id: \.id) { contractor in Button(action: { @@ -422,12 +426,12 @@ struct ContractorPickerView: View { HStack { VStack(alignment: .leading, spacing: 4) { Text(contractor.name) - .foregroundStyle(.primary) + .foregroundColor(Color.appTextPrimary) if let company = contractor.company { Text(company) .font(.caption) - .foregroundStyle(.secondary) + .foregroundColor(Color.appTextSecondary) } if let firstSpecialty = contractor.specialties.first { @@ -437,7 +441,7 @@ struct ContractorPickerView: View { Text(firstSpecialty.name) .font(.caption2) } - .foregroundStyle(.tertiary) + .foregroundColor(Color.appTextSecondary.opacity(0.7)) } } @@ -445,13 +449,17 @@ struct ContractorPickerView: View { if selectedContractor?.id == contractor.id { Image(systemName: "checkmark") - .foregroundStyle(Color.appPrimary) + .foregroundColor(Color.appPrimary) } } } + .listRowBackground(Color.appBackgroundSecondary) } } } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(WarmGradientBackground()) .navigationTitle(L10n.Tasks.selectContractor) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/iosApp/iosApp/Task/TaskSuggestionsView.swift b/iosApp/iosApp/Task/TaskSuggestionsView.swift index eaf4bca..613e616 100644 --- a/iosApp/iosApp/Task/TaskSuggestionsView.swift +++ b/iosApp/iosApp/Task/TaskSuggestionsView.swift @@ -74,8 +74,8 @@ struct TaskSuggestionsView: View { } } .background(Color.appBackgroundSecondary) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .naturalShadow(.medium) } private func categoryColor(for categoryName: String) -> Color { diff --git a/iosApp/iosApp/Task/TaskTemplatesBrowserView.swift b/iosApp/iosApp/Task/TaskTemplatesBrowserView.swift index 37b6ea2..c18304d 100644 --- a/iosApp/iosApp/Task/TaskTemplatesBrowserView.swift +++ b/iosApp/iosApp/Task/TaskTemplatesBrowserView.swift @@ -34,7 +34,7 @@ struct TaskTemplatesBrowserView: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) + .background(WarmGradientBackground()) .searchable(text: $searchText, prompt: "Search templates...") .navigationTitle("Task Templates") .navigationBarTitleDisplayMode(.inline) diff --git a/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift b/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift index 2a36468..5dbc9e0 100644 --- a/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift +++ b/iosApp/iosApp/VerifyEmail/VerifyEmailView.swift @@ -9,121 +9,165 @@ struct VerifyEmailView: View { var body: some View { NavigationStack { ZStack { - Color.appBackgroundPrimary - .ignoresSafeArea() + WarmGradientBackground() - ScrollView { - VStack(spacing: 24) { - Spacer().frame(height: 20) + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.spacious) { + Spacer() + .frame(height: OrganicSpacing.comfortable) - // Header - VStack(spacing: 12) { - Image(systemName: "envelope.badge.shield.half.filled") - .font(.system(size: 60)) - .foregroundStyle(Color.appPrimary.gradient) - .padding(.bottom, 8) + // 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) - Text(L10n.Auth.verifyYourEmail) - .font(.title) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) + Image(systemName: "envelope.badge.shield.half.filled") + .font(.system(size: 48, weight: .medium)) + .foregroundColor(Color.appPrimary) + } - Text(L10n.Auth.verifyMustVerify) - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal) + VStack(spacing: 8) { + Text(L10n.Auth.verifyYourEmail) + .font(.system(size: 26, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text(L10n.Auth.verifyMustVerify) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } } - // Info Card - GroupBox { + // Form Card + VStack(spacing: 20) { + // Info Banner HStack(spacing: 12) { - Image(systemName: "exclamationmark.shield.fill") - .foregroundColor(Color.appAccent) - .font(.title2) + ZStack { + Circle() + .fill(Color.appAccent.opacity(0.1)) + .frame(width: 40, height: 40) + Image(systemName: "exclamationmark.shield.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(Color.appAccent) + } Text(L10n.Auth.verifyCheckInbox) - .font(.subheadline) + .font(.system(size: 14, weight: .semibold)) .foregroundColor(Color.appTextPrimary) - .fontWeight(.semibold) + + Spacer() } - .padding(.vertical, 4) - } - .padding(.horizontal) + .padding(16) + .background(Color.appAccent.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - // Code Input - VStack(alignment: .leading, spacing: 12) { - Text(L10n.Auth.verifyCodeLabel) - .font(.headline) - .foregroundColor(Color.appTextPrimary) - .padding(.horizontal) + // Code Input + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Auth.verifyCodeLabel.uppercased()) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextSecondary) + .tracking(1.2) - TextField("000000", text: $viewModel.code) - .font(.system(size: 32, weight: .semibold, design: .rounded)) - .multilineTextAlignment(.center) - .keyboardType(.numberPad) - .textFieldStyle(.roundedBorder) - .frame(height: 60) - .padding(.horizontal) - .focused($isFocused) - .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verificationCodeField) - .keyboardDismissToolbar() - .onChange(of: viewModel.code) { _, newValue in - // Limit to 6 digits - if newValue.count > 6 { - viewModel.code = String(newValue.prefix(6)) + TextField("000000", text: $viewModel.code) + .font(.system(size: 32, weight: .bold, design: .rounded)) + .multilineTextAlignment(.center) + .keyboardType(.numberPad) + .focused($isFocused) + .keyboardDismissToolbar() + .padding(20) + .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) + ) + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verificationCodeField) + .onChange(of: viewModel.code) { _, newValue in + if newValue.count > 6 { + viewModel.code = String(newValue.prefix(6)) + } + viewModel.code = newValue.filter { $0.isNumber } } - // Only allow numbers - viewModel.code = newValue.filter { $0.isNumber } + + Text(L10n.Auth.verifyCodeMustBe6) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + + // 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)) + } - Text(L10n.Auth.verifyCodeMustBe6) - .font(.caption) - .foregroundColor(Color.appTextSecondary) - .padding(.horizontal) - } - - // Error Message - if let errorMessage = viewModel.errorMessage { - ErrorMessageView(message: errorMessage, onDismiss: viewModel.clearError) - .padding(.horizontal) - } - - // Verify Button - Button(action: { - viewModel.verifyEmail() - }) { - HStack { - if viewModel.isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } else { - Image(systemName: "checkmark.shield.fill") - Text(L10n.Auth.verifyEmailButton) + // Verify Button + Button(action: { + viewModel.verifyEmail() + }) { + HStack(spacing: 8) { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else { + Image(systemName: "checkmark.shield.fill") + } + Text(viewModel.isLoading ? "Verifying..." : L10n.Auth.verifyEmailButton) + .font(.headline) .fontWeight(.semibold) } + .frame(maxWidth: .infinity) + .frame(height: 56) + .foregroundColor(Color.appTextOnPrimary) + .background( + viewModel.code.count == 6 && !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: viewModel.code.count == 6 && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear, + radius: 10, + y: 5 + ) } - .frame(maxWidth: .infinity) - .frame(height: 50) - .background( - viewModel.code.count == 6 && !viewModel.isLoading - ? Color.appPrimary - : Color.gray.opacity(0.3) - ) - .foregroundColor(Color.appTextOnPrimary) - .cornerRadius(12) + .disabled(viewModel.code.count != 6 || viewModel.isLoading) + + // Help Text + Text(L10n.Auth.verifyHelpText) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) } - .disabled(viewModel.code.count != 6 || viewModel.isLoading) - .padding(.horizontal) + .padding(OrganicSpacing.cozy) + .background(OrganicVerifyEmailBackground()) + .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) + .naturalShadow(.pronounced) + .padding(.horizontal, 16) - Spacer().frame(height: 20) - - // Help Text - Text(L10n.Auth.verifyHelpText) - .font(.caption) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) + Spacer() } } } @@ -131,12 +175,17 @@ struct VerifyEmailView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: onLogout) { - HStack(spacing: 4) { + HStack(spacing: 6) { Image(systemName: "rectangle.portrait.and.arrow.right") - .font(.system(size: 16)) + .font(.system(size: 14, weight: .medium)) Text(L10n.Auth.logout) - .font(.subheadline) + .font(.system(size: 14, weight: .semibold)) } + .foregroundColor(Color.appTextSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.appBackgroundSecondary.opacity(0.8)) + .clipShape(Capsule()) } } } @@ -148,10 +197,38 @@ struct VerifyEmailView: View { onVerifySuccess() } } - .handleErrors( - error: viewModel.errorMessage, - onRetry: { viewModel.verifyEmail() } - ) + } + } +} + +// MARK: - Background + +private struct OrganicVerifyEmailBackground: View { + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ZStack { + Color.appBackgroundSecondary + + GeometryReader { geo in + OrganicBlobShape(variation: 0) + .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.5) + .offset(x: geo.size.width * 0.4, y: -geo.size.height * 0.1) + .blur(radius: 20) + } + + GrainTexture(opacity: 0.015) } } }