Apply Warm Organic design system to all iOS views

- 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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-17 09:05:47 -06:00
parent c3a9494b0f
commit b05e52521f
37 changed files with 5009 additions and 2759 deletions

View File

@@ -18,7 +18,7 @@ struct ContractorDetailView: View {
var body: some View { var body: some View {
ZStack { ZStack {
Color.appBackgroundPrimary.ignoresSafeArea() WarmGradientBackground()
contentStateView contentStateView
} }
.onAppear { .onAppear {

View File

@@ -12,7 +12,6 @@ struct ContractorsListView: View {
@State private var showSpecialtyFilter = false @State private var showSpecialtyFilter = false
@State private var showingUpgradePrompt = false @State private var showingUpgradePrompt = false
// Lookups from DataManagerObservable
private var contractorSpecialties: [ContractorSpecialty] { dataManager.contractorSpecialties } private var contractorSpecialties: [ContractorSpecialty] { dataManager.contractorSpecialties }
var specialties: [String] { var specialties: [String] {
@@ -23,7 +22,6 @@ struct ContractorsListView: View {
viewModel.contractors viewModel.contractors
} }
// Client-side filtering since backend doesn't support search/filter params
var filteredContractors: [ContractorSummary] { var filteredContractors: [ContractorSummary] {
contractors.filter { contractor in contractors.filter { contractor in
let matchesSearch = searchText.isEmpty || let matchesSearch = searchText.isEmpty ||
@@ -36,27 +34,26 @@ struct ContractorsListView: View {
} }
} }
// Check if upgrade screen should be shown (disables add button)
private var shouldShowUpgrade: Bool { private var shouldShowUpgrade: Bool {
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors") subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
} }
var body: some View { var body: some View {
ZStack { ZStack {
Color.appBackgroundPrimary.ignoresSafeArea() WarmGradientBackground()
VStack(spacing: 0) { VStack(spacing: 0) {
// Search Bar // Search Bar
SearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder) OrganicSearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder)
.padding(.horizontal, AppSpacing.md) .padding(.horizontal, 16)
.padding(.top, AppSpacing.sm) .padding(.top, 8)
// Active Filters // Active Filters
if showFavoritesOnly || selectedSpecialty != nil { if showFavoritesOnly || selectedSpecialty != nil {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: AppSpacing.xs) { HStack(spacing: 8) {
if showFavoritesOnly { if showFavoritesOnly {
FilterChip( OrganicFilterChip(
title: L10n.Contractors.favorites, title: L10n.Contractors.favorites,
icon: "star.fill", icon: "star.fill",
onRemove: { showFavoritesOnly = false } onRemove: { showFavoritesOnly = false }
@@ -64,31 +61,31 @@ struct ContractorsListView: View {
} }
if let specialty = selectedSpecialty { if let specialty = selectedSpecialty {
FilterChip( OrganicFilterChip(
title: specialty, title: specialty,
onRemove: { selectedSpecialty = nil } onRemove: { selectedSpecialty = nil }
) )
} }
} }
.padding(.horizontal, AppSpacing.md) .padding(.horizontal, 16)
} }
.padding(.vertical, AppSpacing.xs) .padding(.vertical, 8)
} }
// Content - use filteredContractors for client-side filtering // Content
ListAsyncContentView( ListAsyncContentView(
items: filteredContractors, items: filteredContractors,
isLoading: viewModel.isLoading, isLoading: viewModel.isLoading,
errorMessage: viewModel.errorMessage, errorMessage: viewModel.errorMessage,
content: { contractorList in content: { contractorList in
ContractorsContent( OrganicContractorsContent(
contractors: contractorList, contractors: contractorList,
onToggleFavorite: toggleFavorite onToggleFavorite: toggleFavorite
) )
}, },
emptyContent: { emptyContent: {
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") { if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
EmptyContractorsView( OrganicEmptyContractorsView(
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
) )
} else { } else {
@@ -107,20 +104,20 @@ struct ContractorsListView: View {
) )
} }
} }
.navigationTitle(L10n.Contractors.title)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 12) {
// Favorites Filter (client-side, no API call needed) // Favorites Filter
Button(action: { Button(action: {
showFavoritesOnly.toggle() showFavoritesOnly.toggle()
}) { }) {
Image(systemName: showFavoritesOnly ? "star.fill" : "star") Image(systemName: showFavoritesOnly ? "star.fill" : "star")
.font(.system(size: 16, weight: .medium))
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary) .foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
} }
// Specialty Filter (client-side, no API call needed) // Specialty Filter
Menu { Menu {
Button(action: { Button(action: {
selectedSpecialty = nil selectedSpecialty = nil
@@ -139,23 +136,21 @@ struct ContractorsListView: View {
} }
} label: { } label: {
Image(systemName: "line.3.horizontal.decrease.circle") Image(systemName: "line.3.horizontal.decrease.circle")
.font(.system(size: 16, weight: .medium))
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary) .foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
} }
// Add Button (disabled when showing upgrade screen) // Add Button
Button(action: { Button(action: {
let currentCount = viewModel.contractors.count let currentCount = viewModel.contractors.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") { if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
// Track paywall shown
PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount]) PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount])
showingUpgradePrompt = true showingUpgradePrompt = true
} else { } else {
showingAddSheet = true showingAddSheet = true
} }
}) { }) {
Image(systemName: "plus.circle.fill") OrganicToolbarButton(systemName: "plus", isPrimary: true)
.font(.title2)
.foregroundColor(Color.appPrimary)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton) .accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
} }
@@ -177,12 +172,9 @@ struct ContractorsListView: View {
PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown) PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown)
loadContractors() loadContractors()
} }
// No need for onChange on searchText - filtering is client-side
// Contractor specialties are loaded from DataManagerObservable
} }
private func loadContractors(forceRefresh: Bool = false) { private func loadContractors(forceRefresh: Bool = false) {
// Load all contractors, filtering is done client-side
viewModel.loadContractors(forceRefresh: forceRefresh) viewModel.loadContractors(forceRefresh: forceRefresh)
} }
@@ -195,73 +187,82 @@ struct ContractorsListView: View {
} }
} }
// MARK: - Search Bar // MARK: - Organic Search Bar
struct SearchBar: View {
private struct OrganicSearchBar: View {
@Binding var text: String @Binding var text: String
var placeholder: String var placeholder: String
var body: some View { var body: some View {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundColor(Color.appTextSecondary) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
}
TextField(placeholder, text: $text) TextField(placeholder, text: $text)
.font(.body) .font(.system(size: 16, weight: .medium))
if !text.isEmpty { if !text.isEmpty {
Button(action: { text = "" }) { Button(action: { text = "" }) {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
} }
.padding(AppSpacing.sm) .padding(14)
.background(Color.appBackgroundSecondary) .background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y) .naturalShadow(.subtle)
} }
} }
// MARK: - Filter Chip // MARK: - Organic Filter Chip
struct FilterChip: View {
private struct OrganicFilterChip: View {
let title: String let title: String
var icon: String? = nil var icon: String? = nil
let onRemove: () -> Void let onRemove: () -> Void
var body: some View { var body: some View {
HStack(spacing: AppSpacing.xxs) { HStack(spacing: 6) {
if let icon = icon { if let icon = icon {
Image(systemName: icon) Image(systemName: icon)
.font(.caption) .font(.system(size: 12, weight: .semibold))
} }
Text(title) Text(title)
.font(.footnote.weight(.medium)) .font(.system(size: 13, weight: .semibold))
Button(action: onRemove) { Button(action: onRemove) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.caption2) .font(.system(size: 10, weight: .bold))
} }
} }
.padding(.horizontal, AppSpacing.sm) .padding(.horizontal, 12)
.padding(.vertical, AppSpacing.xxs) .padding(.vertical, 8)
.background(Color.appPrimary.opacity(0.1)) .background(Color.appPrimary.opacity(0.15))
.foregroundColor(Color.appPrimary) .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 contractors: [ContractorSummary]
let onToggleFavorite: (Int32) -> Void let onToggleFavorite: (Int32) -> Void
var body: some View { var body: some View {
ScrollView { ScrollView(showsIndicators: false) {
LazyVStack(spacing: AppSpacing.sm) { LazyVStack(spacing: 12) {
ForEach(contractors, id: \.id) { contractor in ForEach(contractors, id: \.id) { contractor in
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) { NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
ContractorCard( OrganicContractorCard(
contractor: contractor, contractor: contractor,
onToggleFavorite: { onToggleFavorite: {
onToggleFavorite(contractor.id) onToggleFavorite(contractor.id)
@@ -271,8 +272,8 @@ private struct ContractorsContent: View {
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} }
} }
.padding(AppSpacing.md) .padding(16)
.padding(.bottom, AppSpacing.xxxl) .padding(.bottom, 40)
} }
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
Color.clear.frame(height: 0) Color.clear.frame(height: 0)
@@ -280,32 +281,189 @@ private struct ContractorsContent: View {
} }
} }
// MARK: - Empty State // MARK: - Organic Contractor Card
struct EmptyContractorsView: View {
let hasFilters: Bool private struct OrganicContractorCard: View {
let contractor: ContractorSummary
let onToggleFavorite: () -> Void
@Environment(\.colorScheme) var colorScheme
var body: some View { var body: some View {
VStack(spacing: AppSpacing.md) { HStack(spacing: 14) {
Image(systemName: "person.badge.plus") // Avatar
.font(.system(size: 64)) ZStack {
.foregroundColor(Color.appTextSecondary.opacity(0.7)) 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) Text(String(contractor.name.prefix(1)).uppercased())
.font(.title3.weight(.semibold)) .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) .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(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)
}
}
// 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 { if !hasFilters {
Text(L10n.Contractors.emptyNoFilters) Text(L10n.Contractors.emptyNoFilters)
.font(.callout) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary.opacity(0.7)) .foregroundColor(Color.appTextSecondary)
} .multilineTextAlignment(.center)
}
.padding(AppSpacing.xl)
} }
} }
struct ContractorsListView_Previews: PreviewProvider { Spacer()
static var previews: some View { }
.padding(24)
.onAppear {
isAnimating = true
}
}
}
#Preview {
NavigationView {
ContractorsListView() ContractorsListView()
} }
} }

View File

@@ -6,20 +6,26 @@ struct EmptyStateView: View {
let message: String let message: String
var body: some View { var body: some View {
VStack(spacing: AppSpacing.md) { VStack(spacing: OrganicSpacing.cozy) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.08))
.frame(width: 100, height: 100)
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 64)) .font(.system(size: 44, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appPrimary.opacity(0.6))
}
Text(title) Text(title)
.font(.title3.weight(.semibold)) .font(.system(size: 18, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextPrimary)
Text(message) Text(message)
.font(.body) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary.opacity(0.7)) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
.padding(AppSpacing.lg) .padding(OrganicSpacing.comfortable)
} }
} }

View File

@@ -190,7 +190,7 @@ struct DocumentDetailView: View {
@ViewBuilder @ViewBuilder
private func documentDetailContent(document: Document) -> some View { private func documentDetailContent(document: Document) -> some View {
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: OrganicSpacing.comfortable) {
// Status Badge (for warranties) // Status Badge (for warranties)
if document.documentType == "warranty" { if document.documentType == "warranty" {
warrantyStatusCard(document: document) warrantyStatusCard(document: document)
@@ -212,9 +212,9 @@ struct DocumentDetailView: View {
} }
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color.appBackgroundSecondary)
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(radius: 2) .naturalShadow(.subtle)
// Warranty/Item Details // Warranty/Item Details
if document.documentType == "warranty" { if document.documentType == "warranty" {
@@ -240,9 +240,9 @@ struct DocumentDetailView: View {
} }
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color.appBackgroundSecondary)
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(radius: 2) .naturalShadow(.subtle)
} }
// Claim Information // Claim Information
@@ -262,9 +262,9 @@ struct DocumentDetailView: View {
} }
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color.appBackgroundSecondary)
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(radius: 2) .naturalShadow(.subtle)
} }
// Dates // Dates
@@ -284,9 +284,9 @@ struct DocumentDetailView: View {
} }
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color.appBackgroundSecondary)
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(radius: 2) .naturalShadow(.subtle)
} }
} }
@@ -301,16 +301,15 @@ struct DocumentDetailView: View {
AuthenticatedImage(mediaURL: image.mediaUrl, contentMode: .fill) AuthenticatedImage(mediaURL: image.mediaUrl, contentMode: .fill)
.frame(height: 100) .frame(height: 100)
.clipped() .clipped()
.cornerRadius(8) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.onTapGesture { .onTapGesture {
selectedImageIndex = index selectedImageIndex = index
showImageViewer = true showImageViewer = true
} }
if index == 5 && document.images.count > 6 { if index == 5 && document.images.count > 6 {
Rectangle() RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.black.opacity(0.6)) .fill(Color.black.opacity(0.6))
.cornerRadius(8)
Text("+\(document.images.count - 6)") Text("+\(document.images.count - 6)")
.foregroundColor(.white) .foregroundColor(.white)
.font(.title2) .font(.title2)
@@ -321,9 +320,9 @@ struct DocumentDetailView: View {
} }
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color.appBackgroundSecondary)
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(radius: 2) .naturalShadow(.subtle)
} }
// Associations // Associations
@@ -341,9 +340,9 @@ struct DocumentDetailView: View {
} }
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color.appBackgroundSecondary)
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(radius: 2) .naturalShadow(.subtle)
// Additional Information // Additional Information
if document.tags != nil || document.notes != nil { if document.tags != nil || document.notes != nil {
@@ -358,9 +357,9 @@ struct DocumentDetailView: View {
} }
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color.appBackgroundSecondary)
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(radius: 2) .naturalShadow(.subtle)
} }
// File Information // File Information
@@ -381,7 +380,7 @@ struct DocumentDetailView: View {
HStack { HStack {
if isDownloading { if isDownloading {
ProgressView() ProgressView()
.tint(.white) .tint(Color.appTextOnPrimary)
.scaleEffect(0.8) .scaleEffect(0.8)
Text("Downloading...") Text("Downloading...")
} else { } else {
@@ -392,8 +391,8 @@ struct DocumentDetailView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding() .padding()
.background(isDownloading ? Color.appPrimary.opacity(0.7) : Color.appPrimary) .background(isDownloading ? Color.appPrimary.opacity(0.7) : Color.appPrimary)
.foregroundColor(.white) .foregroundColor(Color.appTextOnPrimary)
.cornerRadius(8) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} }
.disabled(isDownloading) .disabled(isDownloading)
@@ -404,9 +403,9 @@ struct DocumentDetailView: View {
} }
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color.appBackgroundSecondary)
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(radius: 2) .naturalShadow(.subtle)
} }
// Metadata // Metadata
@@ -424,13 +423,13 @@ struct DocumentDetailView: View {
} }
} }
.padding() .padding()
.background(Color(.systemBackground)) .background(Color.appBackgroundSecondary)
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(radius: 2) .naturalShadow(.subtle)
} }
.padding() .padding()
} }
.background(Color(.systemGroupedBackground)) .background(WarmGradientBackground())
} }
@ViewBuilder @ViewBuilder
@@ -442,11 +441,10 @@ struct DocumentDetailView: View {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(L10n.Documents.status) Text(L10n.Documents.status)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary) .foregroundColor(Color.appTextSecondary)
Text(statusText) Text(statusText)
.font(.title2) .font(.system(size: 20, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(statusColor) .foregroundColor(statusColor)
} }
@@ -455,40 +453,40 @@ struct DocumentDetailView: View {
if document.isActive && daysUntilExpiration >= 0 { if document.isActive && daysUntilExpiration >= 0 {
VStack(alignment: .trailing, spacing: 4) { VStack(alignment: .trailing, spacing: 4) {
Text(L10n.Documents.daysRemaining) Text(L10n.Documents.daysRemaining)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary) .foregroundColor(Color.appTextSecondary)
Text("\(daysUntilExpiration)") Text("\(daysUntilExpiration)")
.font(.title2) .font(.system(size: 20, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(statusColor) .foregroundColor(statusColor)
} }
} }
} }
.padding() .padding()
.background(statusColor.opacity(0.1)) .background(statusColor.opacity(0.12))
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
@ViewBuilder @ViewBuilder
private func sectionHeader(_ title: String) -> some View { private func sectionHeader(_ title: String) -> some View {
Text(title) Text(title)
.font(.headline) .font(.system(size: 16, weight: .bold, design: .rounded))
.fontWeight(.bold) .foregroundColor(Color.appTextPrimary)
} }
@ViewBuilder @ViewBuilder
private func detailRow(label: String, value: String) -> some View { private func detailRow(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(label) Text(label)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary) .foregroundColor(Color.appTextSecondary)
Text(value) Text(value)
.font(.body) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextPrimary)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(12) .padding(14)
.background(Color(.secondarySystemGroupedBackground)) .background(Color.appBackgroundPrimary.opacity(0.5))
.cornerRadius(8) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} }
private func getStatusColor(isActive: Bool, daysUntilExpiration: Int32) -> Color { private func getStatusColor(isActive: Bool, daysUntilExpiration: Int32) -> Color {

View File

@@ -201,7 +201,7 @@ struct DocumentFormView: View {
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary) .background(WarmGradientBackground())
.navigationTitle(isEditMode ? (isWarranty ? L10n.Documents.editWarranty : L10n.Documents.editDocument) : (isWarranty ? L10n.Documents.addWarranty : L10n.Documents.addDocument)) .navigationTitle(isEditMode ? (isWarranty ? L10n.Documents.editWarranty : L10n.Documents.editDocument) : (isWarranty ? L10n.Documents.addWarranty : L10n.Documents.addDocument))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {

View File

@@ -20,15 +20,12 @@ struct DocumentsWarrantiesView: View {
let residenceId: Int32? let residenceId: Int32?
// Client-side filtering for warranties tab
var warranties: [Document] { var warranties: [Document] {
documentViewModel.documents.filter { doc in documentViewModel.documents.filter { doc in
guard doc.documentType == "warranty" else { return false } guard doc.documentType == "warranty" else { return false }
// Apply active filter if enabled
if showActiveOnly && doc.isActive != true { if showActiveOnly && doc.isActive != true {
return false return false
} }
// Apply category filter if selected
if let category = selectedCategory, doc.category != category { if let category = selectedCategory, doc.category != category {
return false return false
} }
@@ -36,11 +33,9 @@ struct DocumentsWarrantiesView: View {
} }
} }
// Client-side filtering for documents tab
var documents: [Document] { var documents: [Document] {
documentViewModel.documents.filter { doc in documentViewModel.documents.filter { doc in
guard doc.documentType != "warranty" else { return false } guard doc.documentType != "warranty" else { return false }
// Apply document type filter if selected
if let docType = selectedDocType, doc.documentType != docType { if let docType = selectedDocType, doc.documentType != docType {
return false return false
} }
@@ -48,38 +43,31 @@ struct DocumentsWarrantiesView: View {
} }
} }
// Check if upgrade screen should be shown (disables add button)
private var shouldShowUpgrade: Bool { private var shouldShowUpgrade: Bool {
subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents")
} }
var body: some View { var body: some View {
ZStack { ZStack {
Color.appBackgroundPrimary.ignoresSafeArea() WarmGradientBackground()
VStack(spacing: 0) { VStack(spacing: 0) {
// Segmented Control for Tabs // Segmented Control
Picker("", selection: $selectedTab) { OrganicSegmentedControl(selection: $selectedTab)
Label(L10n.Documents.warranties, systemImage: "checkmark.shield") .padding(.horizontal, 16)
.tag(DocumentWarrantyTab.warranties) .padding(.top, 8)
Label(L10n.Documents.documents, systemImage: "doc.text")
.tag(DocumentWarrantyTab.documents)
}
.pickerStyle(SegmentedPickerStyle())
.padding(.horizontal, AppSpacing.md)
.padding(.top, AppSpacing.sm)
// Search Bar // Search Bar
SearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder) OrganicDocSearchBar(text: $searchText, placeholder: L10n.Documents.searchPlaceholder)
.padding(.horizontal, AppSpacing.md) .padding(.horizontal, 16)
.padding(.top, AppSpacing.xs) .padding(.top, 8)
// Active Filters // Active Filters
if selectedCategory != nil || selectedDocType != nil || showActiveOnly { if selectedCategory != nil || selectedDocType != nil || (selectedTab == .warranties && showActiveOnly) {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: AppSpacing.xs) { HStack(spacing: 8) {
if selectedTab == .warranties && showActiveOnly { if selectedTab == .warranties && showActiveOnly {
FilterChip( OrganicDocFilterChip(
title: L10n.Documents.activeOnly, title: L10n.Documents.activeOnly,
icon: "checkmark.circle.fill", icon: "checkmark.circle.fill",
onRemove: { showActiveOnly = false } onRemove: { showActiveOnly = false }
@@ -87,22 +75,22 @@ struct DocumentsWarrantiesView: View {
} }
if let category = selectedCategory, selectedTab == .warranties { if let category = selectedCategory, selectedTab == .warranties {
FilterChip( OrganicDocFilterChip(
title: category, title: category,
onRemove: { selectedCategory = nil } onRemove: { selectedCategory = nil }
) )
} }
if let docType = selectedDocType, selectedTab == .documents { if let docType = selectedDocType, selectedTab == .documents {
FilterChip( OrganicDocFilterChip(
title: docType, title: docType,
onRemove: { selectedDocType = nil } onRemove: { selectedDocType = nil }
) )
} }
} }
.padding(.horizontal, AppSpacing.md) .padding(.horizontal, 16)
} }
.padding(.vertical, AppSpacing.xs) .padding(.vertical, 8)
} }
// Content // Content
@@ -119,22 +107,22 @@ struct DocumentsWarrantiesView: View {
} }
} }
} }
.navigationTitle(L10n.Documents.documentsAndWarranties)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 12) {
// Active Filter (for warranties) - client-side, no API call // Active Filter (for warranties)
if selectedTab == .warranties { if selectedTab == .warranties {
Button(action: { Button(action: {
showActiveOnly.toggle() showActiveOnly.toggle()
}) { }) {
Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle") Image(systemName: showActiveOnly ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 16, weight: .medium))
.foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary) .foregroundColor(showActiveOnly ? Color.appPrimary : Color.appTextSecondary)
} }
} }
// Filter Menu - client-side filtering, no API calls // Filter Menu
Menu { Menu {
if selectedTab == .warranties { if selectedTab == .warranties {
Button(action: { Button(action: {
@@ -171,35 +159,29 @@ struct DocumentsWarrantiesView: View {
} }
} label: { } label: {
Image(systemName: "line.3.horizontal.decrease.circle") Image(systemName: "line.3.horizontal.decrease.circle")
.font(.system(size: 16, weight: .medium))
.foregroundColor((selectedCategory != nil || selectedDocType != nil) ? Color.appPrimary : Color.appTextSecondary) .foregroundColor((selectedCategory != nil || selectedDocType != nil) ? Color.appPrimary : Color.appTextSecondary)
} }
// Add Button (disabled when showing upgrade screen) // Add Button
Button(action: { Button(action: {
// Check LIVE document count before adding
let currentCount = documentViewModel.documents.count let currentCount = documentViewModel.documents.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "documents") { if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "documents") {
// Track paywall shown
PostHogAnalytics.shared.capture(AnalyticsEvents.documentsPaywallShown, properties: ["current_count": currentCount]) PostHogAnalytics.shared.capture(AnalyticsEvents.documentsPaywallShown, properties: ["current_count": currentCount])
showingUpgradePrompt = true showingUpgradePrompt = true
} else { } else {
showAddSheet = true showAddSheet = true
} }
}) { }) {
Image(systemName: "plus.circle.fill") OrganicDocToolbarButton()
.font(.title2)
.foregroundColor(Color.appPrimary)
} }
} }
} }
} }
.onAppear { .onAppear {
// Track screen view
PostHogAnalytics.shared.screen(AnalyticsEvents.documentsScreenShown) PostHogAnalytics.shared.screen(AnalyticsEvents.documentsScreenShown)
// Load all documents once - filtering is client-side
loadAllDocuments() loadAllDocuments()
} }
// No need for onChange on selectedTab - filtering is client-side
.sheet(isPresented: $showAddSheet) { .sheet(isPresented: $showAddSheet) {
AddDocumentView( AddDocumentView(
residenceId: residenceId, residenceId: residenceId,
@@ -214,23 +196,151 @@ struct DocumentsWarrantiesView: View {
} }
private func loadAllDocuments(forceRefresh: Bool = false) { 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) documentViewModel.loadDocuments(forceRefresh: forceRefresh)
} }
private func loadWarranties() { private func loadWarranties() {
// Just reload all - filtering happens client-side
loadAllDocuments() loadAllDocuments()
} }
private func loadDocuments() { private func loadDocuments() {
// Just reload all - filtering happens client-side
loadAllDocuments() 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 // MARK: - Supporting Types
extension DocumentCategory: CaseIterable { extension DocumentCategory: CaseIterable {
public static var allCases: [DocumentCategory] { public static var allCases: [DocumentCategory] {
return [.appliance, .hvac, .plumbing, .electrical, .roofing, .structural, .other] return [.appliance, .hvac, .plumbing, .electrical, .roofing, .structural, .other]

View File

@@ -14,7 +14,7 @@ struct MainTabView: View {
} }
.id(refreshID) .id(refreshID)
.tabItem { .tabItem {
Label("Residences", image: "tab_view_house") Label("Home", image: "tab_view_house")
} }
.tag(0) .tag(0)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
@@ -24,7 +24,7 @@ struct MainTabView: View {
} }
.id(refreshID) .id(refreshID)
.tabItem { .tabItem {
Label("Tasks", systemImage: "checkmark.circle.fill") Label("Tasks", systemImage: "checklist")
} }
.tag(1) .tag(1)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab)
@@ -34,7 +34,7 @@ struct MainTabView: View {
} }
.id(refreshID) .id(refreshID)
.tabItem { .tabItem {
Label("Contractors", systemImage: "wrench.and.screwdriver.fill") Label("Pros", systemImage: "wrench.and.screwdriver.fill")
} }
.tag(2) .tag(2)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
@@ -44,7 +44,7 @@ struct MainTabView: View {
} }
.id(refreshID) .id(refreshID)
.tabItem { .tabItem {
Label("Documents", systemImage: "doc.text.fill") Label("Docs", systemImage: "doc.text.fill")
} }
.tag(3) .tag(3)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
@@ -53,23 +53,44 @@ struct MainTabView: View {
.onChange(of: authManager.isAuthenticated) { _ in .onChange(of: authManager.isAuthenticated) { _ in
selectedTab = 0 selectedTab = 0
} }
// Check for pending navigation when view appears (app launched from notification)
.onAppear { .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 { if pushManager.pendingNavigationTaskId != nil {
selectedTab = 1 // Switch to Tasks tab selectedTab = 1
// Note: Don't clear here - AllTasksView will handle navigation and clear it
} }
} }
// Handle push notification deep links - switch to appropriate tab
// The actual task navigation is handled by AllTasksView
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in .onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in
selectedTab = 1 // Switch to Tasks tab selectedTab = 1
} }
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in .onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in
selectedTab = 1 // Switch to Tasks tab selectedTab = 1
} }
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in .onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in
selectedTab = 0 // Switch to Residences tab selectedTab = 0
} }
} }
} }

View File

@@ -10,7 +10,9 @@ struct OnboardingCreateAccountContent: View {
@StateObject private var appleSignInViewModel = AppleSignInViewModel() @StateObject private var appleSignInViewModel = AppleSignInViewModel()
@State private var showingLoginSheet = false @State private var showingLoginSheet = false
@State private var isExpanded = false @State private var isExpanded = false
@State private var isAnimating = false
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
@Environment(\.colorScheme) var colorScheme
enum Field { enum Field {
case username, email, password, confirmPassword case username, email, password, confirmPassword
@@ -24,35 +26,87 @@ struct OnboardingCreateAccountContent: View {
} }
var body: some View { var body: some View {
ScrollView { ZStack {
VStack(spacing: AppSpacing.xl) { WarmGradientBackground()
// 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 // Header
VStack(spacing: AppSpacing.sm) { 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 { ZStack {
Circle() Circle()
.fill(Color.appPrimary.opacity(0.1)) .fill(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80) .frame(width: 80, height: 80)
Image(systemName: "person.badge.plus") Image(systemName: "person.badge.plus")
.font(.system(size: 36)) .font(.system(size: 36, weight: .medium))
.foregroundStyle(Color.appPrimary.gradient) .foregroundColor(.white)
}
.naturalShadow(.pronounced)
} }
Text("Save your home to your account") Text("Save your home to your account")
.font(.title2) .font(.system(size: 24, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle)
Text("Your data will be synced across devices") Text("Your data will be synced across devices")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.padding(.top, AppSpacing.lg) .padding(.top, OrganicSpacing.comfortable)
// Sign in with Apple (Primary) // Sign in with Apple (Primary)
VStack(spacing: AppSpacing.md) { VStack(spacing: 14) {
SignInWithAppleButton( SignInWithAppleButton(
onRequest: { request in onRequest: { request in
request.requestedScopes = [.fullName, .email] request.requestedScopes = [.fullName, .email]
@@ -60,7 +114,7 @@ struct OnboardingCreateAccountContent: View {
onCompletion: { _ in } onCompletion: { _ in }
) )
.frame(height: 56) .frame(height: 56)
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.signInWithAppleButtonStyle(.black) .signInWithAppleButtonStyle(.black)
.disabled(appleSignInViewModel.isLoading) .disabled(appleSignInViewModel.isLoading)
.opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0) .opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0)
@@ -73,122 +127,146 @@ struct OnboardingCreateAccountContent: View {
} }
if appleSignInViewModel.isLoading { if appleSignInViewModel.isLoading {
HStack { HStack(spacing: 10) {
ProgressView() ProgressView()
.tint(Color.appPrimary)
Text("Signing in with Apple...") Text("Signing in with Apple...")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
if let error = appleSignInViewModel.errorMessage { if let error = appleSignInViewModel.errorMessage {
errorMessage(error) OrganicErrorMessage(message: error)
} }
} }
// Divider // Divider
HStack { OrganicDividerWithText(text: "or")
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)
}
// Create Account Form // Create Account Form
VStack(spacing: AppSpacing.md) { VStack(spacing: 14) {
if !isExpanded { if !isExpanded {
// Collapsed state // Collapsed state
Button(action: { Button(action: {
withAnimation(.easeInOut(duration: 0.3)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
isExpanded = true isExpanded = true
} }
}) { }) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.15))
.frame(width: 36, height: 36)
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
.font(.title3) .font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appPrimary)
}
Text("Create Account with Email") Text("Create Account with Email")
.font(.headline) .font(.system(size: 17, weight: .semibold))
.fontWeight(.medium)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56) .frame(height: 56)
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
.background(Color.appPrimary.opacity(0.1)) .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) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.emailSignUpExpandButton)
} else { } else {
// Expanded form // Expanded form
VStack(spacing: AppSpacing.md) { VStack(spacing: 14) {
// Username // Form card
formField( VStack(spacing: 16) {
OrganicOnboardingTextField(
icon: "person.fill", icon: "person.fill",
placeholder: "Username", placeholder: "Username",
text: $viewModel.username, text: $viewModel.username,
field: .username, isFocused: focusedField == .username
keyboardType: .default,
contentType: .username
) )
.focused($focusedField, equals: .username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textContentType(.username)
// Email OrganicOnboardingTextField(
formField(
icon: "envelope.fill", icon: "envelope.fill",
placeholder: "Email", placeholder: "Email",
text: $viewModel.email, text: $viewModel.email,
field: .email, isFocused: focusedField == .email
keyboardType: .emailAddress,
contentType: .emailAddress
) )
.focused($focusedField, equals: .email)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
// Password OrganicOnboardingSecureField(
secureFormField(
icon: "lock.fill", icon: "lock.fill",
placeholder: "Password", placeholder: "Password",
text: $viewModel.password, text: $viewModel.password,
field: .password isFocused: focusedField == .password
) )
.focused($focusedField, equals: .password)
// Confirm Password OrganicOnboardingSecureField(
secureFormField(
icon: "lock.fill", icon: "lock.fill",
placeholder: "Confirm Password", placeholder: "Confirm Password",
text: $viewModel.confirmPassword, text: $viewModel.confirmPassword,
field: .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 { if let error = viewModel.errorMessage {
errorMessage(error) OrganicErrorMessage(message: error)
} }
// Register button // Register button
Button(action: { Button(action: {
viewModel.register() viewModel.register()
}) { }) {
HStack { HStack(spacing: 10) {
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white)) .progressViewStyle(CircularProgressViewStyle(tint: .white))
} }
Text(viewModel.isLoading ? "Creating Account..." : "Create Account") Text(viewModel.isLoading ? "Creating Account..." : "Create Account")
.font(.headline) .font(.system(size: 17, weight: .semibold))
.fontWeight(.semibold)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56) .frame(height: 56)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.background( .background(
isFormValid && !viewModel.isLoading isFormValid && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary) : AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
) )
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: isFormValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) .naturalShadow(isFormValid ? .medium : .subtle)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountButton) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountButton)
.disabled(!isFormValid || viewModel.isLoading) .disabled(!isFormValid || viewModel.isLoading)
@@ -198,24 +276,23 @@ struct OnboardingCreateAccountContent: View {
} }
// Already have an account // Already have an account
HStack(spacing: AppSpacing.xs) { HStack(spacing: 6) {
Text("Already have an account?") Text("Already have an account?")
.font(.body) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
Button("Log in") { Button("Log in") {
showingLoginSheet = true showingLoginSheet = true
} }
.font(.body) .font(.system(size: 15, weight: .semibold))
.fontWeight(.semibold)
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
.padding(.top, AppSpacing.md) .padding(.top, 8)
}
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
} }
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
} }
.background(Color.appBackgroundPrimary)
.sheet(isPresented: $showingLoginSheet) { .sheet(isPresented: $showingLoginSheet) {
LoginView(onLoginSuccess: { LoginView(onLoginSuccess: {
showingLoginSheet = false showingLoginSheet = false
@@ -229,6 +306,7 @@ struct OnboardingCreateAccountContent: View {
} }
} }
.onAppear { .onAppear {
isAnimating = true
// Set up Apple Sign In callback // Set up Apple Sign In callback
appleSignInViewModel.onSignInSuccess = { isVerified in appleSignInViewModel.onSignInSuccess = { isVerified in
AuthenticationManager.shared.login(verified: isVerified) AuthenticationManager.shared.login(verified: isVerified)
@@ -237,74 +315,139 @@ struct OnboardingCreateAccountContent: View {
} }
} }
} }
// MARK: - Form Fields
private func formField(
icon: String,
placeholder: String,
text: Binding<String>,
field: Field,
keyboardType: UIKeyboardType,
contentType: UITextContentType
) -> some View {
HStack(spacing: AppSpacing.sm) {
Image(systemName: icon)
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
TextField(placeholder, text: text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(keyboardType)
.textContentType(contentType)
.focused($focusedField, equals: field)
} }
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary) // MARK: - Organic Onboarding TextField
.cornerRadius(AppRadius.md)
private struct OrganicOnboardingTextField: View {
let icon: String
let placeholder: String
@Binding var text: String
var isFocused: Bool = 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)
}
TextField(placeholder, text: $text)
.font(.system(size: 16, weight: .medium))
}
.padding(14)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: AppRadius.md) RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(focusedField == field ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) .stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 1.5)
) )
} }
}
// MARK: - Organic Onboarding Secure 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)
private func secureFormField(
icon: String,
placeholder: String,
text: Binding<String>,
field: Field
) -> some View {
HStack(spacing: AppSpacing.sm) {
Image(systemName: icon) Image(systemName: icon)
.foregroundColor(Color.appTextSecondary) .font(.system(size: 15, weight: .medium))
.frame(width: 20) .foregroundColor(Color.appPrimary)
}
SecureField(placeholder, text: text) 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) .textContentType(.password)
.focused($focusedField, equals: field)
}
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.md)
.stroke(focusedField == field ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
)
} }
private func errorMessage(_ message: String) -> some View { Button(action: { showPassword.toggle() }) {
HStack(spacing: AppSpacing.sm) { Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
.padding(14)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 1.5)
)
}
}
// MARK: - Organic Error Message
private struct OrganicErrorMessage: View {
let message: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Text(message) Text(message)
.font(.callout) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Spacer() Spacer()
} }
.padding(AppSpacing.md) .padding(14)
.background(Color.appError.opacity(0.1)) .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,14 +458,23 @@ struct OnboardingCreateAccountView: View {
var onBack: () -> Void var onBack: () -> Void
var body: some View { var body: some View {
ZStack {
WarmGradientBackground()
VStack(spacing: 0) { VStack(spacing: 0) {
// Navigation bar // Navigation bar
HStack { HStack {
Button(action: onBack) { Button(action: onBack) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 36, height: 36)
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.title2) .font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
}
Spacer() Spacer()
@@ -331,16 +483,16 @@ struct OnboardingCreateAccountView: View {
Spacer() Spacer()
// Invisible spacer for alignment // Invisible spacer for alignment
Image(systemName: "chevron.left") Circle()
.font(.title2) .fill(Color.clear)
.opacity(0) .frame(width: 36, height: 36)
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, 20)
.padding(.vertical, AppSpacing.md) .padding(.vertical, 12)
OnboardingCreateAccountContent(onAccountCreated: onAccountCreated) OnboardingCreateAccountContent(onAccountCreated: onAccountCreated)
} }
.background(Color.appBackgroundPrimary) }
} }
} }

View File

@@ -13,6 +13,8 @@ struct OnboardingFirstTaskContent: View {
@State private var isCreatingTasks = false @State private var isCreatingTasks = false
@State private var showCustomTaskSheet = false @State private var showCustomTaskSheet = false
@State private var expandedCategory: String? = nil @State private var expandedCategory: String? = nil
@State private var isAnimating = false
@Environment(\.colorScheme) var colorScheme
/// Maximum tasks allowed for free tier (matches API TierLimits) /// Maximum tasks allowed for free tier (matches API TierLimits)
private let maxTasksAllowed = 5 private let maxTasksAllowed = 5
@@ -99,17 +101,57 @@ struct OnboardingFirstTaskContent: View {
} }
var body: some View { var body: some View {
ZStack {
WarmGradientBackground()
// 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)
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) { VStack(spacing: 0) {
ScrollView { ScrollView(showsIndicators: false) {
VStack(spacing: AppSpacing.xl) { VStack(spacing: OrganicSpacing.comfortable) {
// Header with celebration // Header with celebration
VStack(spacing: AppSpacing.md) { VStack(spacing: 16) {
ZStack { ZStack {
// Celebration circles // Celebration circles
Circle() Circle()
.fill( .fill(
RadialGradient( RadialGradient(
colors: [Color.appPrimary.opacity(0.2), Color.clear], colors: [Color.appPrimary.opacity(0.15), Color.clear],
center: .center, center: .center,
startRadius: 30, startRadius: 30,
endRadius: 80 endRadius: 80
@@ -117,11 +159,16 @@ struct OnboardingFirstTaskContent: View {
) )
.frame(width: 140, height: 140) .frame(width: 140, height: 140)
.offset(x: -15, y: -15) .offset(x: -15, y: -15)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
Circle() Circle()
.fill( .fill(
RadialGradient( RadialGradient(
colors: [Color.appAccent.opacity(0.2), Color.clear], colors: [Color.appAccent.opacity(0.15), Color.clear],
center: .center, center: .center,
startRadius: 30, startRadius: 30,
endRadius: 80 endRadius: 80
@@ -129,6 +176,11 @@ struct OnboardingFirstTaskContent: View {
) )
.frame(width: 140, height: 140) .frame(width: 140, height: 140)
.offset(x: 15, y: 15) .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 // Party icon
ZStack { ZStack {
@@ -146,42 +198,40 @@ struct OnboardingFirstTaskContent: View {
.font(.system(size: 36)) .font(.system(size: 36))
.foregroundColor(.white) .foregroundColor(.white)
} }
.shadow(color: Color.appPrimary.opacity(0.4), radius: 15, y: 8) .naturalShadow(.pronounced)
} }
Text("You're all set up!") Text("You're all set up!")
.font(.title) .font(.system(size: 26, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!") Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(4) .lineSpacing(4)
} }
.padding(.top, AppSpacing.lg) .padding(.top, OrganicSpacing.comfortable)
// Selection counter chip // Selection counter chip
HStack(spacing: AppSpacing.sm) { HStack(spacing: 8) {
Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill") Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill")
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary) .foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
Text("\(selectedCount)/\(maxTasksAllowed) tasks selected") Text("\(selectedCount)/\(maxTasksAllowed) tasks selected")
.font(.subheadline) .font(.system(size: 14, weight: .semibold))
.fontWeight(.medium)
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary) .foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, 18)
.padding(.vertical, AppSpacing.sm) .padding(.vertical, 10)
.background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1)) .background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1))
.cornerRadius(AppRadius.xl) .clipShape(Capsule())
.animation(.spring(response: 0.3), value: selectedCount) .animation(.spring(response: 0.3), value: selectedCount)
// Task categories // Task categories
VStack(spacing: AppSpacing.md) { VStack(spacing: 12) {
ForEach(taskCategories) { category in ForEach(taskCategories) { category in
TaskCategorySection( OrganicTaskCategorySection(
category: category, category: category,
selectedTasks: $selectedTasks, selectedTasks: $selectedTasks,
isExpanded: expandedCategory == category.name, isExpanded: expandedCategory == category.name,
@@ -198,17 +248,16 @@ struct OnboardingFirstTaskContent: View {
) )
} }
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, OrganicSpacing.comfortable)
// Quick add all popular // Quick add all popular
Button(action: selectPopularTasks) { Button(action: selectPopularTasks) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 8) {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.headline) .font(.system(size: 16, weight: .semibold))
Text("Add Most Popular") Text("Add Most Popular")
.font(.headline) .font(.system(size: 16, weight: .semibold))
.fontWeight(.medium)
} }
.foregroundStyle( .foregroundStyle(
LinearGradient( LinearGradient(
@@ -226,9 +275,9 @@ struct OnboardingFirstTaskContent: View {
endPoint: .trailing endPoint: .trailing
) )
) )
.cornerRadius(AppRadius.lg) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: AppRadius.lg) RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke( .stroke(
LinearGradient( LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)], colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
@@ -239,25 +288,24 @@ struct OnboardingFirstTaskContent: View {
) )
) )
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, OrganicSpacing.comfortable)
} }
.padding(.bottom, 140) // Space for button .padding(.bottom, 140) // Space for button
} }
// Bottom action area // Bottom action area
VStack(spacing: AppSpacing.md) { VStack(spacing: 14) {
Button(action: addSelectedTasks) { Button(action: addSelectedTasks) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 10) {
if isCreatingTasks { if isCreatingTasks {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white)) .progressViewStyle(CircularProgressViewStyle(tint: .white))
} else { } else {
Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now") Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now")
.font(.headline) .font(.system(size: 17, weight: .bold))
.fontWeight(.bold)
Image(systemName: "arrow.right") Image(systemName: "arrow.right")
.font(.headline) .font(.system(size: 16, weight: .bold))
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -268,14 +316,14 @@ struct OnboardingFirstTaskContent: View {
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing)) ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
: AnyShapeStyle(Color.appTextSecondary.opacity(0.5)) : AnyShapeStyle(Color.appTextSecondary.opacity(0.5))
) )
.cornerRadius(AppRadius.lg) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: selectedCount > 0 ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8) .naturalShadow(selectedCount > 0 ? .medium : .subtle)
} }
.disabled(isCreatingTasks) .disabled(isCreatingTasks)
.animation(.easeInOut(duration: 0.2), value: selectedCount) .animation(.easeInOut(duration: 0.2), value: selectedCount)
} }
.padding(.horizontal, AppSpacing.xl) .padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, AppSpacing.xxxl) .padding(.bottom, OrganicSpacing.airy)
.background( .background(
LinearGradient( LinearGradient(
colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary], colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
@@ -287,8 +335,9 @@ struct OnboardingFirstTaskContent: View {
, alignment: .top , alignment: .top
) )
} }
.background(Color.appBackgroundPrimary) }
.onAppear { .onAppear {
isAnimating = true
// Expand first category by default // Expand first category by default
expandedCategory = taskCategories.first?.name expandedCategory = taskCategories.first?.name
} }
@@ -393,15 +442,17 @@ struct OnboardingTaskCategory: Identifiable {
let tasks: [OnboardingTaskTemplate] let tasks: [OnboardingTaskTemplate]
} }
// MARK: - Task Category Section // MARK: - Organic Task Category Section
struct TaskCategorySection: View { private struct OrganicTaskCategorySection: View {
let category: OnboardingTaskCategory let category: OnboardingTaskCategory
@Binding var selectedTasks: Set<UUID> @Binding var selectedTasks: Set<UUID>
let isExpanded: Bool let isExpanded: Bool
let isAtMaxSelection: Bool let isAtMaxSelection: Bool
var onToggleExpand: () -> Void var onToggleExpand: () -> Void
@Environment(\.colorScheme) var colorScheme
private var selectedInCategory: Int { private var selectedInCategory: Int {
category.tasks.filter { selectedTasks.contains($0.id) }.count category.tasks.filter { selectedTasks.contains($0.id) }.count
} }
@@ -410,7 +461,7 @@ struct TaskCategorySection: View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Category header // Category header
Button(action: onToggleExpand) { Button(action: onToggleExpand) {
HStack(spacing: AppSpacing.md) { HStack(spacing: 14) {
// Category icon // Category icon
ZStack { ZStack {
Circle() Circle()
@@ -424,14 +475,14 @@ struct TaskCategorySection: View {
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
Image(systemName: category.icon) Image(systemName: category.icon)
.font(.title3) .font(.system(size: 18, weight: .medium))
.foregroundColor(.white) .foregroundColor(.white)
} }
.naturalShadow(.subtle)
// Category name // Category name
Text(category.name) Text(category.name)
.font(.headline) .font(.system(size: 16, weight: .semibold))
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Spacer() Spacer()
@@ -439,8 +490,7 @@ struct TaskCategorySection: View {
// Selection badge // Selection badge
if selectedInCategory > 0 { if selectedInCategory > 0 {
Text("\(selectedInCategory)") Text("\(selectedInCategory)")
.font(.caption) .font(.system(size: 12, weight: .bold))
.fontWeight(.bold)
.foregroundColor(.white) .foregroundColor(.white)
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
.background(category.color) .background(category.color)
@@ -449,13 +499,26 @@ struct TaskCategorySection: View {
// Chevron // Chevron
Image(systemName: isExpanded ? "chevron.up" : "chevron.down") Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption) .font(.system(size: 12, weight: .semibold))
.fontWeight(.semibold)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.padding(AppSpacing.md) .padding(14)
.background(Color.appBackgroundSecondary) .background(
.cornerRadius(isExpanded ? AppRadius.lg : AppRadius.lg, corners: isExpanded ? [.topLeft, .topRight] : .allCorners) 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) .buttonStyle(.plain)
@@ -464,7 +527,7 @@ struct TaskCategorySection: View {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(category.tasks) { task in ForEach(category.tasks) { task in
let taskIsSelected = selectedTasks.contains(task.id) let taskIsSelected = selectedTasks.contains(task.id)
OnboardingTaskTemplateRow( OrganicTaskTemplateRow(
template: task, template: task,
isSelected: taskIsSelected, isSelected: taskIsSelected,
isDisabled: isAtMaxSelection && !taskIsSelected, isDisabled: isAtMaxSelection && !taskIsSelected,
@@ -486,16 +549,24 @@ struct TaskCategorySection: View {
} }
} }
.background(Color.appBackgroundSecondary.opacity(0.5)) .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 template: OnboardingTaskTemplate
let isSelected: Bool let isSelected: Bool
let isDisabled: Bool let isDisabled: Bool
@@ -503,7 +574,7 @@ struct OnboardingTaskTemplateRow: View {
var body: some View { var body: some View {
Button(action: onTap) { Button(action: onTap) {
HStack(spacing: AppSpacing.md) { HStack(spacing: 14) {
// Checkbox // Checkbox
ZStack { ZStack {
Circle() Circle()
@@ -516,8 +587,7 @@ struct OnboardingTaskTemplateRow: View {
.frame(width: 28, height: 28) .frame(width: 28, height: 28)
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.caption) .font(.system(size: 12, weight: .bold))
.fontWeight(.bold)
.foregroundColor(.white) .foregroundColor(.white)
} }
} }
@@ -525,12 +595,11 @@ struct OnboardingTaskTemplateRow: View {
// Task info // Task info
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(template.title) Text(template.title)
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.fontWeight(.medium)
.foregroundColor(isDisabled ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary) .foregroundColor(isDisabled ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary)
Text(template.frequency.capitalized) Text(template.frequency.capitalized)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary.opacity(isDisabled ? 0.5 : 1)) .foregroundColor(Color.appTextSecondary.opacity(isDisabled ? 0.5 : 1))
} }
@@ -538,11 +607,11 @@ struct OnboardingTaskTemplateRow: View {
// Task icon // Task icon
Image(systemName: template.icon) Image(systemName: template.icon)
.font(.title3) .font(.system(size: 18, weight: .medium))
.foregroundColor(template.color.opacity(isDisabled ? 0.3 : 0.6)) .foregroundColor(template.color.opacity(isDisabled ? 0.3 : 0.6))
} }
.padding(.horizontal, AppSpacing.md) .padding(.horizontal, 14)
.padding(.vertical, AppSpacing.sm) .padding(.vertical, 12)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -569,6 +638,9 @@ struct OnboardingFirstTaskView: View {
var onSkip: () -> Void var onSkip: () -> Void
var body: some View { var body: some View {
ZStack {
WarmGradientBackground()
VStack(spacing: 0) { VStack(spacing: 0) {
// Navigation bar // Navigation bar
HStack { HStack {
@@ -576,20 +648,19 @@ struct OnboardingFirstTaskView: View {
Button(action: onSkip) { Button(action: onSkip) {
Text("Skip") Text("Skip")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.fontWeight(.medium)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, 20)
.padding(.vertical, AppSpacing.md) .padding(.vertical, 12)
OnboardingFirstTaskContent( OnboardingFirstTaskContent(
residenceName: residenceName, residenceName: residenceName,
onTaskAdded: onTaskAdded onTaskAdded: onTaskAdded
) )
} }
.background(Color.appBackgroundPrimary) }
} }
} }

View File

@@ -9,50 +9,129 @@ struct OnboardingJoinResidenceContent: View {
@State private var shareCode: String = "" @State private var shareCode: String = ""
@State private var isLoading = false @State private var isLoading = false
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var isAnimating = false
@FocusState private var isCodeFieldFocused: Bool @FocusState private var isCodeFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme
private var isCodeValid: Bool { private var isCodeValid: Bool {
shareCode.count == 6 shareCode.count == 6
} }
var body: some View { var body: some View {
ZStack {
WarmGradientBackground()
// 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
)
)
.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)
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)
}
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
// Content // Content
VStack(spacing: AppSpacing.xl) { VStack(spacing: OrganicSpacing.comfortable) {
// Icon // Icon with pulsing glow
ZStack { ZStack {
Circle() Circle()
.fill(Color.appPrimary.opacity(0.1)) .fill(
.frame(width: 100, height: 100) 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") Image(systemName: "person.2.badge.key.fill")
.font(.system(size: 44)) .font(.system(size: 40, weight: .medium))
.foregroundStyle(Color.appPrimary.gradient) .foregroundColor(.white)
}
.naturalShadow(.pronounced)
} }
// Title // Title
VStack(spacing: AppSpacing.sm) { VStack(spacing: 10) {
Text("Join a Residence") Text("Join a Residence")
.font(.title2) .font(.system(size: 26, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text("Enter the 6-character code shared with you to join an existing home.") Text("Enter the 6-character code shared with you to join an existing home.")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(4)
.padding(.horizontal, 20)
} }
// Code input // Code input card
VStack(alignment: .leading, spacing: AppSpacing.xs) { VStack(spacing: 16) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 14) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "key.fill") Image(systemName: "key.fill")
.foregroundColor(Color.appTextSecondary) .font(.system(size: 17, weight: .medium))
.frame(width: 20) .foregroundColor(Color.appPrimary)
}
TextField("Enter share code", text: $shareCode) TextField("Enter share code", text: $shareCode)
.font(.system(size: 20, weight: .semibold, design: .monospaced))
.textInputAutocapitalization(.characters) .textInputAutocapitalization(.characters)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused($isCodeFieldFocused) .focused($isCodeFieldFocused)
@@ -65,38 +144,45 @@ struct OnboardingJoinResidenceContent: View {
errorMessage = nil errorMessage = nil
} }
} }
.padding(AppSpacing.md) .padding(18)
.background(Color.appBackgroundSecondary) .background(
.cornerRadius(AppRadius.md) ZStack {
.overlay( Color.appBackgroundSecondary
RoundedRectangle(cornerRadius: AppRadius.md) GrainTexture(opacity: 0.01)
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
)
} }
.padding(.horizontal, AppSpacing.xl) )
.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 // Error message
if let error = errorMessage { if let error = errorMessage {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Text(error) Text(error)
.font(.callout) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Spacer() Spacer()
} }
.padding(AppSpacing.md) .padding(14)
.background(Color.appError.opacity(0.1)) .background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.horizontal, AppSpacing.xl) .padding(.horizontal, OrganicSpacing.comfortable)
} }
// Loading indicator // Loading indicator
if isLoading { if isLoading {
HStack { HStack(spacing: 10) {
ProgressView() ProgressView()
.tint(Color.appPrimary)
Text("Joining residence...") Text("Joining residence...")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
@@ -106,26 +192,32 @@ struct OnboardingJoinResidenceContent: View {
// Join button // Join button
Button(action: joinResidence) { Button(action: joinResidence) {
Text("Join Residence") HStack(spacing: 10) {
.font(.headline) if isLoading {
.fontWeight(.semibold) ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(isLoading ? "Joining..." : "Join Residence")
.font(.system(size: 17, weight: .semibold))
}
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56) .frame(height: 56)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.background( .background(
isCodeValid && !isLoading isCodeValid && !isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary) : AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
) )
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: isCodeValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) .naturalShadow(isCodeValid ? .medium : .subtle)
} }
.disabled(!isCodeValid || isLoading) .disabled(!isCodeValid || isLoading)
.padding(.horizontal, AppSpacing.xl) .padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, AppSpacing.xxxl) .padding(.bottom, OrganicSpacing.airy)
}
} }
.background(Color.appBackgroundPrimary)
.onAppear { .onAppear {
isAnimating = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isCodeFieldFocused = true isCodeFieldFocused = true
} }
@@ -161,6 +253,9 @@ struct OnboardingJoinResidenceView: View {
var onSkip: () -> Void var onSkip: () -> Void
var body: some View { var body: some View {
ZStack {
WarmGradientBackground()
VStack(spacing: 0) { VStack(spacing: 0) {
// Navigation bar // Navigation bar
HStack { HStack {
@@ -168,17 +263,16 @@ struct OnboardingJoinResidenceView: View {
Button(action: onSkip) { Button(action: onSkip) {
Text("Skip") Text("Skip")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.fontWeight(.medium)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, 20)
.padding(.vertical, AppSpacing.md) .padding(.vertical, 12)
OnboardingJoinResidenceContent(onJoined: onJoined) OnboardingJoinResidenceContent(onJoined: onJoined)
} }
.background(Color.appBackgroundPrimary) }
} }
} }

View File

@@ -7,6 +7,8 @@ struct OnboardingNameResidenceContent: View {
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
@State private var showSuggestions = false @State private var showSuggestions = false
@State private var isAnimating = false
@Environment(\.colorScheme) var colorScheme
private var isValid: Bool { private var isValid: Bool {
!residenceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !residenceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
@@ -21,18 +23,58 @@ struct OnboardingNameResidenceContent: View {
] ]
var body: some View { var body: some View {
ZStack {
WarmGradientBackground()
// 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: 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)
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: 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)
}
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
// Content // Content
VStack(spacing: AppSpacing.xl) { VStack(spacing: OrganicSpacing.comfortable) {
// Animated house icon // Animated house icon
ZStack { ZStack {
// Colorful background circles // Pulsing glow circles
Circle() Circle()
.fill( .fill(
RadialGradient( RadialGradient(
colors: [Color.appAccent.opacity(0.2), Color.clear], colors: [Color.appAccent.opacity(0.15), Color.clear],
center: .center, center: .center,
startRadius: 30, startRadius: 30,
endRadius: 80 endRadius: 80
@@ -40,11 +82,16 @@ struct OnboardingNameResidenceContent: View {
) )
.frame(width: 160, height: 160) .frame(width: 160, height: 160)
.offset(x: -20, y: -20) .offset(x: -20, y: -20)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
Circle() Circle()
.fill( .fill(
RadialGradient( RadialGradient(
colors: [Color.appPrimary.opacity(0.2), Color.clear], colors: [Color.appPrimary.opacity(0.15), Color.clear],
center: .center, center: .center,
startRadius: 30, startRadius: 30,
endRadius: 80 endRadius: 80
@@ -52,36 +99,51 @@ struct OnboardingNameResidenceContent: View {
) )
.frame(width: 160, height: 160) .frame(width: 160, height: 160)
.offset(x: 20, y: 20) .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 // Main icon
Image("icon") Image("icon")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
.shadow(color: Color.appPrimary.opacity(0.3), radius: 15, y: 8) .naturalShadow(.pronounced)
} }
// Title with playful wording // Title with playful wording
VStack(spacing: AppSpacing.md) { VStack(spacing: 12) {
Text("Let's give your place a name!") Text("Let's give your place a name!")
.font(.title) .font(.system(size: 26, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle)
Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.") Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(4) .lineSpacing(4)
} }
// Text field with gradient border when focused // Text field with organic styling
VStack(alignment: .leading, spacing: AppSpacing.sm) { VStack(alignment: .leading, spacing: 12) {
HStack(spacing: AppSpacing.sm) { 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") Image(systemName: "house.fill")
.font(.title3) .font(.system(size: 18, weight: .medium))
.foregroundStyle( .foregroundStyle(
LinearGradient( LinearGradient(
colors: [Color.appPrimary, Color.appAccent], colors: [Color.appPrimary, Color.appAccent],
@@ -89,11 +151,10 @@ struct OnboardingNameResidenceContent: View {
endPoint: .bottomTrailing endPoint: .bottomTrailing
) )
) )
.frame(width: 24) }
TextField("The Smith Residence", text: $residenceName) TextField("The Smith Residence", text: $residenceName)
.font(.body) .font(.system(size: 17, weight: .medium))
.fontWeight(.medium)
.textInputAutocapitalization(.words) .textInputAutocapitalization(.words)
.focused($isTextFieldFocused) .focused($isTextFieldFocused)
.submitLabel(.continue) .submitLabel(.continue)
@@ -107,36 +168,41 @@ struct OnboardingNameResidenceContent: View {
if !residenceName.isEmpty { if !residenceName.isEmpty {
Button(action: { residenceName = "" }) { Button(action: { residenceName = "" }) {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(Color.appTextSecondary.opacity(0.5)) .foregroundColor(Color.appTextSecondary.opacity(0.5))
} }
} }
} }
.padding(AppSpacing.lg) .padding(18)
.background(Color.appBackgroundSecondary) .background(
.cornerRadius(AppRadius.lg) ZStack {
Color.appBackgroundSecondary
GrainTexture(opacity: 0.01)
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: AppRadius.lg) RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke( .stroke(
isTextFieldFocused isTextFieldFocused
? LinearGradient(colors: [Color.appPrimary, Color.appAccent], startPoint: .leading, endPoint: .trailing) ? 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), : LinearGradient(colors: [Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0.2)], startPoint: .leading, endPoint: .trailing),
lineWidth: 2 lineWidth: 2
) )
) )
.shadow(color: isTextFieldFocused ? Color.appPrimary.opacity(0.15) : .clear, radius: 12, y: 4) .naturalShadow(isTextFieldFocused ? .medium : .subtle)
.animation(.easeInOut(duration: 0.2), value: isTextFieldFocused) .animation(.easeInOut(duration: 0.2), value: isTextFieldFocused)
// Name suggestions // Name suggestions
if residenceName.isEmpty { if residenceName.isEmpty {
VStack(alignment: .leading, spacing: AppSpacing.xs) { VStack(alignment: .leading, spacing: 8) {
Text("Need inspiration?") Text("Need inspiration?")
.font(.caption) .font(.system(size: 13, weight: .semibold))
.fontWeight(.medium)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.padding(.top, AppSpacing.xs) .padding(.top, 4)
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 10) {
ForEach(nameSuggestions, id: \.self) { suggestion in ForEach(nameSuggestions, id: \.self) { suggestion in
Button(action: { Button(action: {
withAnimation(.spring(response: 0.3)) { withAnimation(.spring(response: 0.3)) {
@@ -144,13 +210,12 @@ struct OnboardingNameResidenceContent: View {
} }
}) { }) {
Text(suggestion) Text(suggestion)
.font(.caption) .font(.system(size: 13, weight: .semibold))
.fontWeight(.medium)
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
.padding(.horizontal, AppSpacing.md) .padding(.horizontal, 14)
.padding(.vertical, AppSpacing.sm) .padding(.vertical, 10)
.background(Color.appPrimary.opacity(0.1)) .background(Color.appPrimary.opacity(0.1))
.cornerRadius(AppRadius.md) .clipShape(Capsule())
} }
} }
} }
@@ -158,20 +223,19 @@ struct OnboardingNameResidenceContent: View {
} }
} }
} }
.padding(.horizontal, AppSpacing.xl) .padding(.horizontal, OrganicSpacing.comfortable)
} }
Spacer() Spacer()
// Continue button // Continue button
Button(action: onContinue) { Button(action: onContinue) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 10) {
Text("That's Perfect!") Text("That's Perfect!")
.font(.headline) .font(.system(size: 17, weight: .bold))
.fontWeight(.bold)
Image(systemName: "arrow.right") Image(systemName: "arrow.right")
.font(.headline) .font(.system(size: 16, weight: .bold))
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56) .frame(height: 56)
@@ -179,19 +243,20 @@ struct OnboardingNameResidenceContent: View {
.background( .background(
isValid isValid
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing)) ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
: AnyShapeStyle(Color.appTextSecondary.opacity(0.5)) : AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
) )
.cornerRadius(AppRadius.lg) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: isValid ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8) .naturalShadow(isValid ? .medium : .subtle)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton)
.disabled(!isValid) .disabled(!isValid)
.padding(.horizontal, AppSpacing.xl) .padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, AppSpacing.xxxl) .padding(.bottom, OrganicSpacing.airy)
.animation(.easeInOut(duration: 0.2), value: isValid) .animation(.easeInOut(duration: 0.2), value: isValid)
} }
.background(Color.appBackgroundPrimary) }
.onAppear { .onAppear {
isAnimating = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isTextFieldFocused = true isTextFieldFocused = true
} }
@@ -207,14 +272,23 @@ struct OnboardingNameResidenceView: View {
var onBack: () -> Void var onBack: () -> Void
var body: some View { var body: some View {
ZStack {
WarmGradientBackground()
VStack(spacing: 0) { VStack(spacing: 0) {
// Navigation bar // Navigation bar
HStack { HStack {
Button(action: onBack) { Button(action: onBack) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 36, height: 36)
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.title2) .font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
}
Spacer() Spacer()
@@ -223,19 +297,19 @@ struct OnboardingNameResidenceView: View {
Spacer() Spacer()
// Invisible spacer for alignment // Invisible spacer for alignment
Image(systemName: "chevron.left") Circle()
.font(.title2) .fill(Color.clear)
.opacity(0) .frame(width: 36, height: 36)
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, 20)
.padding(.vertical, AppSpacing.md) .padding(.vertical, 12)
OnboardingNameResidenceContent( OnboardingNameResidenceContent(
residenceName: $residenceName, residenceName: $residenceName,
onContinue: onContinue onContinue: onContinue
) )
} }
.background(Color.appBackgroundPrimary) }
} }
} }

View File

@@ -8,6 +8,7 @@ struct OnboardingSubscriptionContent: View {
@State private var isLoading = false @State private var isLoading = false
@State private var selectedPlan: PricingPlan = .yearly @State private var selectedPlan: PricingPlan = .yearly
@State private var animateBadge = false @State private var animateBadge = false
@Environment(\.colorScheme) var colorScheme
private let benefits: [SubscriptionBenefit] = [ private let benefits: [SubscriptionBenefit] = [
SubscriptionBenefit( SubscriptionBenefit(
@@ -49,16 +50,56 @@ struct OnboardingSubscriptionContent: View {
] ]
var body: some View { var body: some View {
ScrollView {
VStack(spacing: AppSpacing.xl) {
// Header with animated crown
VStack(spacing: AppSpacing.md) {
ZStack { ZStack {
// Glow effect WarmGradientBackground()
// 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
)
)
.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)
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)
}
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
// Header with animated crown
VStack(spacing: 16) {
ZStack {
// Pulsing glow effect
Circle() Circle()
.fill( .fill(
RadialGradient( RadialGradient(
colors: [Color.appAccent.opacity(0.3), Color.clear], colors: [Color.appAccent.opacity(0.25), Color.appAccent.opacity(0.05), Color.clear],
center: .center, center: .center,
startRadius: 30, startRadius: 30,
endRadius: 100 endRadius: 100
@@ -84,22 +125,21 @@ struct OnboardingSubscriptionContent: View {
.font(.system(size: 44)) .font(.system(size: 44))
.foregroundColor(.white) .foregroundColor(.white)
} }
.shadow(color: Color.appAccent.opacity(0.5), radius: 20, y: 10) .naturalShadow(.pronounced)
} }
// Pro badge // Pro badge
HStack(spacing: AppSpacing.xs) { HStack(spacing: 6) {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
Text("CASERA PRO") Text("CASERA PRO")
.font(.headline) .font(.system(size: 14, weight: .black))
.fontWeight(.black)
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
Image(systemName: "sparkles") Image(systemName: "sparkles")
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, 18)
.padding(.vertical, AppSpacing.sm) .padding(.vertical, 10)
.background( .background(
LinearGradient( LinearGradient(
colors: [Color.appAccent.opacity(0.15), Color(hex: "#FF9500")?.opacity(0.15) ?? Color.orange.opacity(0.15)], colors: [Color.appAccent.opacity(0.15), Color(hex: "#FF9500")?.opacity(0.15) ?? Color.orange.opacity(0.15)],
@@ -110,75 +150,89 @@ struct OnboardingSubscriptionContent: View {
.clipShape(Capsule()) .clipShape(Capsule())
Text("Take your home management\nto the next level") Text("Take your home management\nto the next level")
.font(.title2) .font(.system(size: 22, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(4) .lineSpacing(4)
// Social proof // Social proof
HStack(spacing: AppSpacing.xs) { HStack(spacing: 6) {
ForEach(0..<5, id: \.self) { _ in ForEach(0..<5, id: \.self) { _ in
Image(systemName: "star.fill") Image(systemName: "star.fill")
.font(.caption) .font(.system(size: 12))
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
} }
Text("4.9") Text("4.9")
.font(.subheadline) .font(.system(size: 14, weight: .bold))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text("• 10K+ homeowners") Text("• 10K+ homeowners")
.font(.subheadline) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
.padding(.top, AppSpacing.lg) .padding(.top, OrganicSpacing.comfortable)
// Benefits list with gradient icons // Benefits list card
VStack(spacing: AppSpacing.sm) { VStack(spacing: 10) {
ForEach(benefits) { benefit in ForEach(benefits) { benefit in
SubscriptionBenefitRow(benefit: benefit) OrganicSubscriptionBenefitRow(benefit: benefit)
} }
} }
.padding(.horizontal, AppSpacing.lg) .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 // Pricing plans
VStack(spacing: AppSpacing.md) { VStack(spacing: 14) {
Text("Choose your plan") Text("Choose your plan")
.font(.headline) .font(.system(size: 17, weight: .semibold))
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
// Yearly plan (best value) // Yearly plan (best value)
PricingPlanCard( OrganicPricingPlanCard(
plan: .yearly, plan: .yearly,
isSelected: selectedPlan == .yearly, isSelected: selectedPlan == .yearly,
onSelect: { selectedPlan = .yearly } onSelect: { selectedPlan = .yearly }
) )
// Monthly plan // Monthly plan
PricingPlanCard( OrganicPricingPlanCard(
plan: .monthly, plan: .monthly,
isSelected: selectedPlan == .monthly, isSelected: selectedPlan == .monthly,
onSelect: { selectedPlan = .monthly } onSelect: { selectedPlan = .monthly }
) )
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, 20)
// CTA buttons // CTA buttons
VStack(spacing: AppSpacing.md) { VStack(spacing: 14) {
Button(action: startFreeTrial) { Button(action: startFreeTrial) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 10) {
if isLoading { if isLoading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white)) .progressViewStyle(CircularProgressViewStyle(tint: .white))
} else { } else {
Text("Start 7-Day Free Trial") Text("Start 7-Day Free Trial")
.font(.headline) .font(.system(size: 17, weight: .bold))
.fontWeight(.bold)
Image(systemName: "arrow.right") Image(systemName: "arrow.right")
.font(.headline) .font(.system(size: 16, weight: .bold))
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -191,8 +245,8 @@ struct OnboardingSubscriptionContent: View {
endPoint: .trailing endPoint: .trailing
) )
) )
.cornerRadius(AppRadius.lg) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: Color.appAccent.opacity(0.4), radius: 15, y: 8) .naturalShadow(.medium)
} }
.disabled(isLoading) .disabled(isLoading)
@@ -201,29 +255,28 @@ struct OnboardingSubscriptionContent: View {
onSubscribe() onSubscribe()
}) { }) {
Text("Continue with Free") Text("Continue with Free")
.font(.subheadline) .font(.system(size: 15, weight: .semibold))
.fontWeight(.medium)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
// Legal text // Legal text
VStack(spacing: AppSpacing.xs) { VStack(spacing: 4) {
Text("7-day free trial, then \(selectedPlan == .yearly ? "$23.99/year" : "$2.99/month")") Text("7-day free trial, then \(selectedPlan == .yearly ? "$23.99/year" : "$2.99/month")")
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
Text("Cancel anytime in Settings • No commitment") Text("Cancel anytime in Settings • No commitment")
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary.opacity(0.7)) .foregroundColor(Color.appTextSecondary.opacity(0.7))
} }
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.top, AppSpacing.xs) .padding(.top, 4)
}
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
} }
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
} }
} }
.background(Color.appBackgroundPrimary)
.onAppear { .onAppear {
animateBadge = true 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 plan: PricingPlan
let isSelected: Bool let isSelected: Bool
var onSelect: () -> Void var onSelect: () -> Void
@Environment(\.colorScheme) var colorScheme
var body: some View { var body: some View {
Button(action: onSelect) { Button(action: onSelect) {
HStack { HStack {
@@ -320,19 +375,17 @@ struct PricingPlanCard: View {
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 8) {
Text(plan.title) Text(plan.title)
.font(.headline) .font(.system(size: 16, weight: .semibold))
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
if let savings = plan.savings { if let savings = plan.savings {
Text(savings) Text(savings)
.font(.caption) .font(.system(size: 10, weight: .bold))
.fontWeight(.bold)
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, AppSpacing.sm) .padding(.horizontal, 8)
.padding(.vertical, 2) .padding(.vertical, 3)
.background( .background(
LinearGradient( LinearGradient(
colors: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green], colors: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green],
@@ -346,7 +399,7 @@ struct PricingPlanCard: View {
if let monthlyEquivalent = plan.monthlyEquivalent { if let monthlyEquivalent = plan.monthlyEquivalent {
Text(monthlyEquivalent) Text(monthlyEquivalent)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
@@ -355,28 +408,43 @@ struct PricingPlanCard: View {
VStack(alignment: .trailing, spacing: 0) { VStack(alignment: .trailing, spacing: 0) {
Text(plan.price) Text(plan.price)
.font(.title3) .font(.system(size: 20, weight: .bold))
.fontWeight(.bold)
.foregroundColor(isSelected ? Color.appAccent : Color.appTextPrimary) .foregroundColor(isSelected ? Color.appAccent : Color.appTextPrimary)
Text(plan.period) Text(plan.period)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
.padding(AppSpacing.lg) .padding(18)
.background(Color.appBackgroundSecondary) .background(
.cornerRadius(AppRadius.lg) 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( .overlay(
RoundedRectangle(cornerRadius: AppRadius.lg) RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke( .stroke(
isSelected isSelected
? LinearGradient(colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], startPoint: .leading, endPoint: .trailing) ? LinearGradient(colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], startPoint: .leading, endPoint: .trailing)
: LinearGradient(colors: [Color.clear, Color.clear], startPoint: .leading, endPoint: .trailing), : LinearGradient(colors: [Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0.2)], startPoint: .leading, endPoint: .trailing),
lineWidth: 2 lineWidth: isSelected ? 2 : 1
) )
) )
.shadow(color: isSelected ? Color.appAccent.opacity(0.15) : .clear, radius: 10, y: 4) .naturalShadow(isSelected ? .medium : .subtle)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.animation(.easeInOut(duration: 0.2), value: isSelected) .animation(.easeInOut(duration: 0.2), value: isSelected)
@@ -404,13 +472,13 @@ struct SubscriptionBenefit: Identifiable {
let gradient: [Color] let gradient: [Color]
} }
// MARK: - Subscription Benefit Row // MARK: - Organic Subscription Benefit Row
struct SubscriptionBenefitRow: View { private struct OrganicSubscriptionBenefitRow: View {
let benefit: SubscriptionBenefit let benefit: SubscriptionBenefit
var body: some View { var body: some View {
HStack(spacing: AppSpacing.md) { HStack(spacing: 14) {
// Gradient icon // Gradient icon
ZStack { ZStack {
Circle() Circle()
@@ -421,22 +489,21 @@ struct SubscriptionBenefitRow: View {
endPoint: .bottomTrailing endPoint: .bottomTrailing
) )
) )
.frame(width: 44, height: 44) .frame(width: 40, height: 40)
Image(systemName: benefit.icon) Image(systemName: benefit.icon)
.font(.title3) .font(.system(size: 17, weight: .medium))
.foregroundColor(.white) .foregroundColor(.white)
} }
.shadow(color: benefit.gradient[0].opacity(0.3), radius: 8, y: 4) .naturalShadow(.subtle)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(benefit.title) Text(benefit.title)
.font(.subheadline) .font(.system(size: 14, weight: .semibold))
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text(benefit.description) Text(benefit.description)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.lineLimit(2) .lineLimit(2)
} }
@@ -444,12 +511,11 @@ struct SubscriptionBenefitRow: View {
Spacer() Spacer()
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.caption) .font(.system(size: 12, weight: .bold))
.fontWeight(.bold)
.foregroundColor(benefit.gradient[0]) .foregroundColor(benefit.gradient[0])
} }
.padding(.horizontal, AppSpacing.md) .padding(.horizontal, 4)
.padding(.vertical, AppSpacing.sm) .padding(.vertical, 6)
} }
} }

View File

@@ -56,38 +56,40 @@ struct OnboardingValuePropsContent: View {
] ]
var body: some View { var body: some View {
ZStack {
WarmGradientBackground()
VStack(spacing: 0) { VStack(spacing: 0) {
// Feature cards in a tab view // Feature cards in a tab view
TabView(selection: $currentPage) { TabView(selection: $currentPage) {
ForEach(Array(features.enumerated()), id: \.offset) { index, feature in ForEach(Array(features.enumerated()), id: \.offset) { index, feature in
FeatureCard(feature: feature, isActive: currentPage == index) OrganicFeatureCard(feature: feature, isActive: currentPage == index)
.tag(index) .tag(index)
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, 20)
} }
} }
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
// Custom page indicator // Custom page indicator
HStack(spacing: AppSpacing.sm) { HStack(spacing: 10) {
ForEach(0..<features.count, id: \.self) { index in ForEach(0..<features.count, id: \.self) { index in
Capsule() Capsule()
.fill(currentPage == index ? Color.appPrimary : Color.appTextSecondary.opacity(0.3)) .fill(currentPage == index ? Color.appPrimary : Color.appTextSecondary.opacity(0.25))
.frame(width: currentPage == index ? 24 : 8, height: 8) .frame(width: currentPage == index ? 28 : 8, height: 8)
.animation(.spring(response: 0.3), value: currentPage) .animation(.spring(response: 0.3), value: currentPage)
} }
} }
.padding(.bottom, AppSpacing.xl) .padding(.bottom, OrganicSpacing.comfortable)
// Continue button // Continue button
Button(action: onContinue) { Button(action: onContinue) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 10) {
Text("I'm Ready!") Text("I'm Ready!")
.font(.headline) .font(.system(size: 17, weight: .bold))
.fontWeight(.bold)
Image(systemName: "arrow.right") Image(systemName: "arrow.right")
.font(.headline) .font(.system(size: 16, weight: .bold))
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56) .frame(height: 56)
@@ -99,13 +101,13 @@ struct OnboardingValuePropsContent: View {
endPoint: .trailing endPoint: .trailing
) )
) )
.cornerRadius(AppRadius.lg) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: Color.appPrimary.opacity(0.4), radius: 15, y: 8) .naturalShadow(.medium)
}
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
} }
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
} }
.background(Color.appBackgroundPrimary)
} }
} }
@@ -122,17 +124,18 @@ struct FeatureHighlight: Identifiable {
let statLabel: String let statLabel: String
} }
// MARK: - Feature Card // MARK: - Organic Feature Card
struct FeatureCard: View { struct OrganicFeatureCard: View {
let feature: FeatureHighlight let feature: FeatureHighlight
let isActive: Bool let isActive: Bool
@State private var appeared = false @State private var appeared = false
@Environment(\.colorScheme) var colorScheme
var body: some View { var body: some View {
VStack(spacing: AppSpacing.lg) { VStack(spacing: OrganicSpacing.cozy) {
Spacer(minLength: AppSpacing.md) Spacer(minLength: 16)
// Large icon with gradient background // Large icon with gradient background
ZStack { ZStack {
@@ -140,17 +143,18 @@ struct FeatureCard: View {
Circle() Circle()
.fill( .fill(
RadialGradient( RadialGradient(
colors: [feature.gradient[0].opacity(0.3), Color.clear], colors: [feature.gradient[0].opacity(0.25), Color.clear],
center: .center, center: .center,
startRadius: 30, startRadius: 30,
endRadius: 80 endRadius: 90
) )
) )
.frame(width: 160, height: 160) .frame(width: 180, height: 180)
.scaleEffect(appeared ? 1 : 0.8) .scaleEffect(appeared ? 1 : 0.8)
.opacity(appeared ? 1 : 0) .opacity(appeared ? 1 : 0)
// Icon circle // Icon circle
ZStack {
Circle() Circle()
.fill( .fill(
LinearGradient( LinearGradient(
@@ -160,26 +164,25 @@ struct FeatureCard: View {
) )
) )
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
.shadow(color: feature.gradient[0].opacity(0.5), radius: 15, y: 8)
Image(systemName: feature.icon) Image(systemName: feature.icon)
.font(.system(size: 44)) .font(.system(size: 44))
.foregroundColor(.white) .foregroundColor(.white)
} }
.naturalShadow(.pronounced)
}
.scaleEffect(appeared ? 1 : 0.5) .scaleEffect(appeared ? 1 : 0.5)
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: appeared) .animation(.spring(response: 0.5, dampingFraction: 0.7), value: appeared)
// Text content // Text content
VStack(spacing: AppSpacing.sm) { VStack(spacing: 10) {
Text(feature.title) Text(feature.title)
.font(.title2) .font(.system(size: 24, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text(feature.subtitle) Text(feature.subtitle)
.font(.subheadline) .font(.system(size: 15, weight: .semibold))
.fontWeight(.medium)
.foregroundStyle( .foregroundStyle(
LinearGradient( LinearGradient(
colors: feature.gradient, colors: feature.gradient,
@@ -190,21 +193,21 @@ struct FeatureCard: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text(feature.description) Text(feature.description)
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(3) .lineSpacing(4)
.padding(.horizontal, AppSpacing.sm) .padding(.horizontal, 8)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
.opacity(appeared ? 1 : 0) .opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 20) .offset(y: appeared ? 0 : 20)
.animation(.easeOut(duration: 0.4).delay(0.2), value: appeared) .animation(.easeOut(duration: 0.4).delay(0.2), value: appeared)
// Stat highlight // Stat highlight card
VStack(spacing: AppSpacing.xs) { VStack(spacing: 6) {
Text(feature.statNumber) Text(feature.statNumber)
.font(.system(size: 32, weight: .bold, design: .rounded)) .font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundStyle( .foregroundStyle(
LinearGradient( LinearGradient(
colors: feature.gradient, colors: feature.gradient,
@@ -214,22 +217,36 @@ struct FeatureCard: View {
) )
Text(feature.statLabel) Text(feature.statLabel)
.font(.caption) .font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, OrganicSpacing.cozy)
.padding(.vertical, AppSpacing.md) .padding(.vertical, 16)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background( .background(
RoundedRectangle(cornerRadius: AppRadius.lg) ZStack {
.fill(Color.appBackgroundSecondary) Color.appBackgroundSecondary
// Subtle blob accent
GeometryReader { geo in
OrganicBlobShape(variation: 1)
.fill(feature.gradient[0].opacity(colorScheme == .dark ? 0.06 : 0.04))
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.8)
.offset(x: geo.size.width * 0.6, y: 0)
.blur(radius: 15)
}
GrainTexture(opacity: 0.015)
}
) )
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.naturalShadow(.subtle)
.opacity(appeared ? 1 : 0) .opacity(appeared ? 1 : 0)
.animation(.easeOut(duration: 0.4).delay(0.4), value: appeared) .animation(.easeOut(duration: 0.4).delay(0.4), value: appeared)
Spacer(minLength: AppSpacing.md) Spacer(minLength: 16)
} }
.onChange(of: isActive) { _, newValue in .onChange(of: isActive) { _, newValue in
if newValue { if newValue {
@@ -257,14 +274,23 @@ struct OnboardingValuePropsView: View {
var onBack: () -> Void var onBack: () -> Void
var body: some View { var body: some View {
ZStack {
WarmGradientBackground()
VStack(spacing: 0) { VStack(spacing: 0) {
// Navigation bar // Navigation bar
HStack { HStack {
Button(action: onBack) { Button(action: onBack) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 36, height: 36)
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.title2) .font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
}
Spacer() Spacer()
@@ -274,17 +300,16 @@ struct OnboardingValuePropsView: View {
Button(action: onSkip) { Button(action: onSkip) {
Text("Skip") Text("Skip")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.fontWeight(.medium)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, 20)
.padding(.vertical, AppSpacing.md) .padding(.vertical, 12)
OnboardingValuePropsContent(onContinue: onContinue) OnboardingValuePropsContent(onContinue: onContinue)
} }
.background(Color.appBackgroundPrimary) }
} }
} }

View File

@@ -8,46 +8,125 @@ struct OnboardingVerifyEmailContent: View {
@StateObject private var viewModel = VerifyEmailViewModel() @StateObject private var viewModel = VerifyEmailViewModel()
@FocusState private var isCodeFieldFocused: Bool @FocusState private var isCodeFieldFocused: Bool
@State private var hasCalledOnVerified = false @State private var hasCalledOnVerified = false
@State private var isAnimating = false
@Environment(\.colorScheme) var colorScheme
var body: some View { var body: some View {
ZStack {
WarmGradientBackground()
// 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
)
)
.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)
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)
}
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
// Content // Content
VStack(spacing: AppSpacing.xl) { VStack(spacing: OrganicSpacing.comfortable) {
// Icon // Icon with pulsing glow
ZStack { ZStack {
Circle() Circle()
.fill(Color.appPrimary.opacity(0.1)) .fill(
.frame(width: 100, height: 100) 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") Image(systemName: "envelope.badge.fill")
.font(.system(size: 44)) .font(.system(size: 40, weight: .medium))
.foregroundStyle(Color.appPrimary.gradient) .foregroundColor(.white)
}
.naturalShadow(.pronounced)
} }
// Title // Title
VStack(spacing: AppSpacing.sm) { VStack(spacing: 10) {
Text("Verify your email") Text("Verify your email")
.font(.title2) .font(.system(size: 26, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle)
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.") Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineSpacing(4)
.padding(.horizontal, 20)
} }
// Code input // Code input card
VStack(alignment: .leading, spacing: AppSpacing.xs) { VStack(spacing: 16) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 14) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "key.fill") Image(systemName: "key.fill")
.foregroundColor(Color.appTextSecondary) .font(.system(size: 17, weight: .medium))
.frame(width: 20) .foregroundColor(Color.appPrimary)
}
TextField("Enter 6-digit code", text: $viewModel.code) TextField("Enter 6-digit code", text: $viewModel.code)
.font(.system(size: 20, weight: .semibold, design: .monospaced))
.keyboardType(.numberPad) .keyboardType(.numberPad)
.textContentType(.oneTimeCode) .textContentType(.oneTimeCode)
.focused($isCodeFieldFocused) .focused($isCodeFieldFocused)
@@ -64,48 +143,62 @@ struct OnboardingVerifyEmailContent: View {
} }
} }
} }
.padding(AppSpacing.md) .padding(18)
.background(Color.appBackgroundSecondary) .background(
.cornerRadius(AppRadius.md) ZStack {
.overlay( Color.appBackgroundSecondary
RoundedRectangle(cornerRadius: AppRadius.md) GrainTexture(opacity: 0.01)
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
)
} }
.padding(.horizontal, AppSpacing.xl) )
.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 // Error message
if let error = viewModel.errorMessage { if let error = viewModel.errorMessage {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Text(error) Text(error)
.font(.callout) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Spacer() Spacer()
} }
.padding(AppSpacing.md) .padding(14)
.background(Color.appError.opacity(0.1)) .background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.horizontal, AppSpacing.xl) .padding(.horizontal, OrganicSpacing.comfortable)
} }
// Loading indicator // Loading indicator
if viewModel.isLoading { if viewModel.isLoading {
HStack { HStack(spacing: 10) {
ProgressView() ProgressView()
.tint(Color.appPrimary)
Text("Verifying...") Text("Verifying...")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
// Resend code hint // 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") Text("Didn't receive a code? Check your spam folder or re-register")
.font(.caption) .font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
.padding(.top, 8)
}
Spacer() Spacer()
@@ -113,28 +206,34 @@ struct OnboardingVerifyEmailContent: View {
Button(action: { Button(action: {
viewModel.verifyEmail() viewModel.verifyEmail()
}) { }) {
Text("Verify") HStack(spacing: 10) {
.font(.headline) if viewModel.isLoading {
.fontWeight(.semibold) ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isLoading ? "Verifying..." : "Verify")
.font(.system(size: 17, weight: .semibold))
}
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56) .frame(height: 56)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.background( .background(
viewModel.code.count == 6 && !viewModel.isLoading viewModel.code.count == 6 && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary) : AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
) )
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: viewModel.code.count == 6 ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5) .naturalShadow(viewModel.code.count == 6 ? .medium : .subtle)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyButton) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyButton)
.disabled(viewModel.code.count != 6 || viewModel.isLoading) .disabled(viewModel.code.count != 6 || viewModel.isLoading)
.padding(.horizontal, AppSpacing.xl) .padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, AppSpacing.xxxl) .padding(.bottom, OrganicSpacing.airy)
}
} }
.background(Color.appBackgroundPrimary)
.onAppear { .onAppear {
print("🏠 ONBOARDING: OnboardingVerifyEmailContent appeared") print("🏠 ONBOARDING: OnboardingVerifyEmailContent appeared")
isAnimating = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isCodeFieldFocused = true isCodeFieldFocused = true
} }
@@ -159,13 +258,20 @@ struct OnboardingVerifyEmailView: View {
var onLogout: () -> Void var onLogout: () -> Void
var body: some View { var body: some View {
ZStack {
WarmGradientBackground()
VStack(spacing: 0) { VStack(spacing: 0) {
// Navigation bar // Navigation bar
HStack { HStack {
// Logout option // Logout option
Button(action: onLogout) { Button(action: onLogout) {
HStack(spacing: 6) {
Image(systemName: "arrow.left")
.font(.system(size: 14, weight: .medium))
Text("Back") Text("Back")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
}
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
@@ -177,15 +283,15 @@ struct OnboardingVerifyEmailView: View {
// Invisible spacer for alignment // Invisible spacer for alignment
Text("Back") Text("Back")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.opacity(0) .opacity(0)
} }
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, 20)
.padding(.vertical, AppSpacing.md) .padding(.vertical, 12)
OnboardingVerifyEmailContent(onVerified: onVerified) OnboardingVerifyEmailContent(onVerified: onVerified)
} }
.background(Color.appBackgroundPrimary) }
} }
} }

View File

@@ -7,31 +7,99 @@ struct OnboardingWelcomeView: View {
var onLogin: () -> Void var onLogin: () -> Void
@State private var showingLoginSheet = false @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 { var body: some View {
ZStack {
WarmGradientBackground()
// 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)
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)
}
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
// Hero section // Hero section
VStack(spacing: AppSpacing.xl) { 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 // App icon
Image("icon") Image("icon")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 120, height: 120) .frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: AppRadius.xxl)) .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.shadow(color: Color.appPrimary.opacity(0.3), radius: 20, y: 10) .naturalShadow(.pronounced)
.scaleEffect(iconScale)
.opacity(iconOpacity)
}
// Welcome text // Welcome text
VStack(spacing: AppSpacing.sm) { VStack(spacing: 10) {
Text("Welcome to Casera") Text("Welcome to Casera")
.font(.largeTitle) .font(.system(size: 32, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
Text("Your home maintenance companion") Text("Your home maintenance companion")
.font(.title3) .font(.system(size: 17, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
@@ -40,47 +108,49 @@ struct OnboardingWelcomeView: View {
Spacer() Spacer()
// Action buttons // Action buttons
VStack(spacing: AppSpacing.md) { VStack(spacing: 14) {
// Primary CTA - Start Fresh // Primary CTA - Start Fresh
Button(action: onStartFresh) { Button(action: onStartFresh) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 12) {
Image("icon") Image("icon")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
Text("Start Fresh") Text("Start Fresh")
.font(.headline) .font(.system(size: 17, weight: .semibold))
.fontWeight(.semibold)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56) .frame(height: 56)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.background( .background(
LinearGradient( LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
startPoint: .topLeading, startPoint: .topLeading,
endPoint: .bottomTrailing endPoint: .bottomTrailing
) )
) )
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5) .naturalShadow(.medium)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
// Secondary CTA - Join Existing // Secondary CTA - Join Existing
Button(action: onJoinExisting) { Button(action: onJoinExisting) {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 12) {
Image(systemName: "person.2.fill") Image(systemName: "person.2.fill")
.font(.title3) .font(.system(size: 18, weight: .medium))
Text("I have a code to join") Text("I have a code to join")
.font(.headline) .font(.system(size: 17, weight: .medium))
.fontWeight(.medium)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56) .frame(height: 56)
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
.background(Color.appPrimary.opacity(0.1)) .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.joinExistingButton) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
@@ -89,22 +159,38 @@ struct OnboardingWelcomeView: View {
showingLoginSheet = true showingLoginSheet = true
}) { }) {
Text("Already have an account? Log in") Text("Already have an account? Log in")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
.padding(.top, AppSpacing.sm) .padding(.top, 8)
}
.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) { .sheet(isPresented: $showingLoginSheet) {
LoginView(onLoginSuccess: { LoginView(onLoginSuccess: {
showingLoginSheet = false showingLoginSheet = false
onLogin() onLogin()
}) })
} }
.onAppear {
isAnimating = true
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
iconScale = 1.0
iconOpacity = 1.0
}
}
} }
} }

View File

@@ -7,32 +7,71 @@ struct ForgotPasswordView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
Form { ZStack {
// Header Section WarmGradientBackground()
Section {
VStack(spacing: 12) {
Image(systemName: "key.fill")
.font(.system(size: 60))
.foregroundStyle(Color.appPrimary.gradient)
.padding(.vertical)
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: "key.fill")
.font(.system(size: 48, weight: .medium))
.foregroundColor(Color.appPrimary)
}
VStack(spacing: 8) {
Text("Forgot Password?") Text("Forgot Password?")
.font(.title2) .font(.system(size: 26, weight: .bold, design: .rounded))
.fontWeight(.bold) .foregroundColor(Color.appTextPrimary)
Text("Enter your email address and we'll send you a verification code") Text("Enter your email address and we'll send you a verification code")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal)
} }
.frame(maxWidth: .infinity)
.padding(.vertical)
} }
.listRowBackground(Color.clear)
// Email Input Section // Form Card
Section { 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) TextField("Email Address", text: $viewModel.email)
.font(.system(size: 16, weight: .medium))
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.keyboardType(.emailAddress) .keyboardType(.emailAddress)
@@ -44,83 +83,137 @@ struct ForgotPasswordView: View {
.onChange(of: viewModel.email) { _, _ in .onChange(of: viewModel.email) { _, _ in
viewModel.clearError() viewModel.clearError()
} }
} header: { }
Text("Email") .padding(16)
} footer: { .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") Text("We'll send a 6-digit verification code to this address")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
} }
.listRowBackground(Color.appBackgroundSecondary)
// Error/Success Messages // Error Message
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
Section { HStack(spacing: 10) {
Label { Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage) Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
} icon: { Spacer()
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
} }
} .padding(16)
.listRowBackground(Color.appBackgroundSecondary) .background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
// Success Message
if let successMessage = viewModel.successMessage { if let successMessage = viewModel.successMessage {
Section { HStack(spacing: 10) {
Label {
Text(successMessage)
.foregroundColor(Color.appAccent)
} icon: {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
Text(successMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appAccent)
Spacer()
} }
} .padding(16)
.listRowBackground(Color.appBackgroundSecondary) .background(Color.appAccent.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
// Send Code Button // Send Code Button
Section {
Button(action: { Button(action: {
viewModel.requestPasswordReset() viewModel.requestPasswordReset()
}) { }) {
HStack { HStack(spacing: 8) {
Spacer()
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else { } else {
Label("Send Reset Code", systemImage: "envelope.fill") Image(systemName: "envelope.fill")
}
Text(viewModel.isLoading ? "Sending..." : "Send Reset Code")
.font(.headline)
.fontWeight(.semibold) .fontWeight(.semibold)
} }
Spacer() .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) .disabled(viewModel.email.isEmpty || viewModel.isLoading)
Button(action: { // Back to Login
dismiss() Button(action: { dismiss() }) {
}) {
HStack {
Spacer()
Text("Back to Login") Text("Back to Login")
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
}
.padding(.top, 8)
}
.padding(OrganicSpacing.cozy)
.background(OrganicFormCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer() Spacer()
} }
} }
} }
.listRowBackground(Color.appBackgroundSecondary)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("Reset Password")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear { .onAppear {
isEmailFocused = true 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)
} }
} }
} }

View File

@@ -12,244 +12,6 @@ struct ResetPasswordView: View {
case newPassword, confirmPassword 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 // Computed Properties
private var hasLetter: Bool { private var hasLetter: Bool {
viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil
@@ -271,6 +33,348 @@ struct ResetPasswordView: View {
hasNumber && hasNumber &&
passwordsMatch 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 { #Preview {

View File

@@ -7,149 +7,205 @@ struct VerifyResetCodeView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
Form { ZStack {
// Header Section WarmGradientBackground()
Section {
VStack(spacing: 12) {
Image(systemName: "envelope.badge.fill")
.font(.system(size: 60))
.foregroundStyle(Color.appPrimary.gradient)
.padding(.vertical)
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: "envelope.badge.fill")
.font(.system(size: 48, weight: .medium))
.foregroundColor(Color.appPrimary)
}
VStack(spacing: 8) {
Text("Check Your Email") Text("Check Your Email")
.font(.title2) .font(.system(size: 26, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text("We sent a 6-digit code to") Text("We sent a 6-digit code to")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
Text(viewModel.email) Text(viewModel.email)
.font(.subheadline) .font(.system(size: 15, weight: .bold, design: .rounded))
.fontWeight(.semibold) .foregroundColor(Color.appPrimary)
.foregroundColor(Color.appTextPrimary)
} }
.frame(maxWidth: .infinity)
.padding(.vertical)
} }
.listRowBackground(Color.clear)
// Info Section // Form Card
Section { VStack(spacing: 20) {
Label { // Timer Info
Text("Code expires in 15 minutes") HStack(spacing: 12) {
.fontWeight(.semibold) ZStack {
.foregroundColor(Color.appTextPrimary) Circle()
} icon: { .fill(Color.appAccent.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "clock.fill") Image(systemName: "clock.fill")
.font(.system(size: 18, weight: .medium))
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
} }
}
.listRowBackground(Color.appBackgroundSecondary)
// Code Input Section Text("Code expires in 15 minutes")
Section { .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) TextField("000000", text: $viewModel.code)
.font(.system(size: 32, weight: .semibold, design: .rounded)) .font(.system(size: 32, weight: .bold, design: .rounded))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.keyboardType(.numberPad) .keyboardType(.numberPad)
.focused($isCodeFocused) .focused($isCodeFocused)
.keyboardDismissToolbar() .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 .onChange(of: viewModel.code) { _, newValue in
// Limit to 6 digits
if newValue.count > 6 { if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6)) viewModel.code = String(newValue.prefix(6))
} }
// Only allow numbers
viewModel.code = newValue.filter { $0.isNumber } viewModel.code = newValue.filter { $0.isNumber }
viewModel.clearError() viewModel.clearError()
} }
} header: {
Text("Verification Code")
} footer: {
Text("Enter the 6-digit code from your email") Text("Enter the 6-digit code from your email")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
} }
.listRowBackground(Color.appBackgroundSecondary)
// Error/Success Messages // Error Message
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
Section { HStack(spacing: 10) {
Label { Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage) Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
} icon: { Spacer()
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
} }
} .padding(16)
.listRowBackground(Color.appBackgroundSecondary) .background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
// Success Message
if let successMessage = viewModel.successMessage { if let successMessage = viewModel.successMessage {
Section { HStack(spacing: 10) {
Label {
Text(successMessage)
.foregroundColor(Color.appPrimary)
} icon: {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
Text(successMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
Spacer()
} }
} .padding(16)
.listRowBackground(Color.appBackgroundSecondary) .background(Color.appPrimary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
// Verify Button // Verify Button
Section {
Button(action: { Button(action: {
viewModel.verifyResetCode() viewModel.verifyResetCode()
}) { }) {
HStack { HStack(spacing: 8) {
Spacer()
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else { } else {
Label("Verify Code", systemImage: "checkmark.shield.fill") Image(systemName: "checkmark.shield.fill")
}
Text(viewModel.isLoading ? "Verifying..." : "Verify Code")
.font(.headline)
.fontWeight(.semibold) .fontWeight(.semibold)
} }
Spacer() .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) .disabled(viewModel.code.count != 6 || viewModel.isLoading)
}
.listRowBackground(Color.appBackgroundSecondary) OrganicDivider()
.padding(.vertical, 4)
// Help Section // Help Section
Section {
VStack(spacing: 12) { VStack(spacing: 12) {
Text("Didn't receive the code?") Text("Didn't receive the code?")
.font(.subheadline) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
Button(action: { Button(action: {
// Clear code and go back to request new one
viewModel.code = "" viewModel.code = ""
viewModel.clearError() viewModel.clearError()
viewModel.currentStep = .requestCode viewModel.currentStep = .requestCode
}) { }) {
Text("Send New Code") Text("Send New Code")
.font(.subheadline) .font(.system(size: 15, weight: .bold, design: .rounded))
.fontWeight(.semibold) .foregroundColor(Color.appPrimary)
} }
Text("Check your spam folder if you don't see it") Text("Check your spam folder if you don't see it")
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
.frame(maxWidth: .infinity)
} }
.listRowBackground(Color.clear) .padding(OrganicSpacing.cozy)
.background(OrganicVerifyCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
}
}
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("Verify Code")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
.toolbar { .toolbar {
@@ -157,22 +213,51 @@ struct VerifyResetCodeView: View {
Button(action: { Button(action: {
viewModel.moveToPreviousStep() viewModel.moveToPreviousStep()
}) { }) {
HStack(spacing: 4) { HStack(spacing: 6) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
.font(.system(size: 16)) .font(.system(size: 14, weight: .semibold))
Text("Back") Text("Back")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
} }
.foregroundColor(Color.appPrimary)
} }
} }
} }
.onAppear { .onAppear {
isCodeFocused = true 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)
} }
} }
} }

View File

@@ -6,128 +6,213 @@ struct RegisterView: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
@State private var showVerifyEmail = false @State private var showVerifyEmail = false
@State private var isPasswordVisible = false
@State private var isConfirmPasswordVisible = false
enum Field { enum Field {
case username, email, password, confirmPassword 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 { var body: some View {
NavigationView { NavigationView {
Form { ZStack {
Section { WarmGradientBackground()
VStack(spacing: 16) {
Image(systemName: "person.badge.plus")
.font(.system(size: 60))
.foregroundStyle(Color.appPrimary.gradient)
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.spacious) {
Spacer()
.frame(height: OrganicSpacing.comfortable)
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Image(systemName: "person.badge.plus")
.font(.system(size: 48, weight: .medium))
.foregroundColor(Color.appPrimary)
}
VStack(spacing: 8) {
Text(L10n.Auth.joinCasera) Text(L10n.Auth.joinCasera)
.font(.largeTitle) .font(.system(size: 26, weight: .bold, design: .rounded))
.fontWeight(.bold) .foregroundColor(Color.appTextPrimary)
Text(L10n.Auth.startManaging) Text(L10n.Auth.startManaging)
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.frame(maxWidth: .infinity)
.padding(.vertical)
} }
.listRowBackground(Color.clear)
Section { // Registration Card
TextField(L10n.Auth.registerUsername, text: $viewModel.username) 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) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.textContentType(.username) .textContentType(.username)
.focused($focusedField, equals: .username)
.submitLabel(.next) .submitLabel(.next)
.onSubmit { .onSubmit { focusedField = .email }
focusedField = .email
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField)
TextField(L10n.Auth.registerEmail, text: $viewModel.email) // Email Field
OrganicTextField(
label: nil,
placeholder: L10n.Auth.registerEmail,
text: $viewModel.email,
icon: "envelope.fill",
isFocused: focusedField == .email
)
.focused($focusedField, equals: .email)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.keyboardType(.emailAddress) .keyboardType(.emailAddress)
.textContentType(.emailAddress) .textContentType(.emailAddress)
.focused($focusedField, equals: .email)
.submitLabel(.next) .submitLabel(.next)
.onSubmit { .onSubmit { focusedField = .password }
focusedField = .password
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField)
} header: {
Text(L10n.Auth.accountInfo)
}
.listRowBackground(Color.appBackgroundSecondary)
Section { OrganicDivider()
// Using .newPassword enables iOS Strong Password generation .padding(.vertical, 4)
// iOS will automatically offer to save to iCloud Keychain after successful registration
SecureField(L10n.Auth.registerPassword, text: $viewModel.password) // Password Field
.textContentType(.newPassword) OrganicSecureField(
label: L10n.Auth.security,
placeholder: L10n.Auth.registerPassword,
text: $viewModel.password,
isVisible: $isPasswordVisible,
isFocused: focusedField == .password
)
.focused($focusedField, equals: .password) .focused($focusedField, equals: .password)
.textContentType(.newPassword)
.submitLabel(.next) .submitLabel(.next)
.onSubmit { .onSubmit { focusedField = .confirmPassword }
focusedField = .confirmPassword
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField)
SecureField(L10n.Auth.registerConfirmPassword, text: $viewModel.confirmPassword) // Confirm Password Field
.textContentType(.newPassword) OrganicSecureField(
label: nil,
placeholder: L10n.Auth.registerConfirmPassword,
text: $viewModel.confirmPassword,
isVisible: $isConfirmPasswordVisible,
isFocused: focusedField == .confirmPassword
)
.focused($focusedField, equals: .confirmPassword) .focused($focusedField, equals: .confirmPassword)
.textContentType(.newPassword)
.submitLabel(.go) .submitLabel(.go)
.onSubmit { .onSubmit { viewModel.register() }
viewModel.register()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField)
} header: {
Text(L10n.Auth.security)
} footer: {
Text(L10n.Auth.passwordSuggestion)
}
.listRowBackground(Color.appBackgroundSecondary)
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 { if let errorMessage = viewModel.errorMessage {
Section { HStack(spacing: 10) {
HStack { Image(systemName: "exclamationmark.circle.fill")
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Text(errorMessage) Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
.font(.subheadline) Spacer()
} }
} .padding(16)
.listRowBackground(Color.appBackgroundSecondary) .background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
Section { // Register Button
Button(action: viewModel.register) { Button(action: viewModel.register) {
HStack { HStack(spacing: 8) {
Spacer()
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
} else { .progressViewStyle(CircularProgressViewStyle(tint: .white))
Text(L10n.Auth.registerButton) }
Text(viewModel.isLoading ? L10n.Auth.creatingAccount : L10n.Auth.registerButton)
.font(.headline)
.fontWeight(.semibold) .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() Spacer()
} }
} }
.disabled(viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton)
} }
.listRowBackground(Color.appBackgroundSecondary)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Auth.registerTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button(L10n.Common.cancel) { Button(action: { dismiss() }) {
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) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerCancelButton)
} }
@@ -135,23 +220,16 @@ struct RegisterView: View {
.fullScreenCover(isPresented: $viewModel.isRegistered) { .fullScreenCover(isPresented: $viewModel.isRegistered) {
VerifyEmailView( VerifyEmailView(
onVerifySuccess: { onVerifySuccess: {
// User has verified their email - mark as verified
// This will update RootView to show the main app
AuthenticationManager.shared.markVerified() AuthenticationManager.shared.markVerified()
showVerifyEmail = false showVerifyEmail = false
dismiss() dismiss()
}, },
onLogout: { onLogout: {
// Logout and return to login screen
AuthenticationManager.shared.logout() AuthenticationManager.shared.logout()
dismiss() dismiss()
} }
) )
} }
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.register() }
)
.onAppear { .onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.registrationScreenShown) 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 { #Preview {
RegisterView() RegisterView()
} }

View File

@@ -7,70 +7,170 @@ struct JoinResidenceView: View {
let onJoined: () -> Void let onJoined: () -> Void
@State private var shareCode: String = "" @State private var shareCode: String = ""
@FocusState private var isCodeFocused: Bool
var body: some View { var body: some View {
NavigationView { NavigationView {
Form { ZStack {
Section { WarmGradientBackground()
TextField(L10n.Residences.shareCode, text: $shareCode)
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.spacious) {
Spacer()
.frame(height: OrganicSpacing.comfortable)
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Image(systemName: "person.badge.plus")
.font(.system(size: 48, weight: .medium))
.foregroundColor(Color.appPrimary)
}
VStack(spacing: 8) {
Text(L10n.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)
}
}
// 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) .textInputAutocapitalization(.characters)
.autocorrectionDisabled() .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 .onChange(of: shareCode) { newValue in
// Limit to 6 characters and uppercase
if newValue.count > 6 { if newValue.count > 6 {
shareCode = String(newValue.prefix(6)) shareCode = String(newValue.prefix(6))
} }
shareCode = shareCode.uppercased() shareCode = shareCode.uppercased()
viewModel.clearError() viewModel.clearError()
} }
.disabled(viewModel.isLoading)
} header: {
Text(L10n.Residences.enterShareCode)
} footer: {
Text(L10n.Residences.shareCodeFooter) Text(L10n.Residences.shareCodeFooter)
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.listRowBackground(Color.appBackgroundSecondary)
// Error Message
if let error = viewModel.errorMessage { if let error = viewModel.errorMessage {
Section { HStack(spacing: 10) {
Text(error) Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Text(error)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
} }
.listRowBackground(Color.appBackgroundSecondary) .padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
Section { // Join Button
Button(action: joinResidence) { Button(action: joinResidence) {
HStack { HStack(spacing: 8) {
Spacer()
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle()) .progressViewStyle(CircularProgressViewStyle(tint: .white))
} else { } else {
Text(L10n.Residences.joinButton) Image(systemName: "person.badge.plus")
}
Text(viewModel.isLoading ? "Joining..." : L10n.Residences.joinButton)
.font(.headline)
.fontWeight(.semibold) .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() Spacer()
} }
} }
.disabled(shareCode.count != 6 || viewModel.isLoading)
} }
.listRowBackground(Color.appBackgroundSecondary)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Residences.joinTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button(L10n.Common.cancel) { Button(action: { dismiss() }) {
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) .disabled(viewModel.isLoading)
} }
} }
.onAppear {
isCodeFocused = true
}
} }
} }
@@ -85,7 +185,38 @@ struct JoinResidenceView: View {
onJoined() onJoined()
dismiss() 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)
} }
} }
} }

View File

@@ -21,8 +21,7 @@ struct ManageUsersView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
ZStack { ZStack {
Color.appBackgroundPrimary WarmGradientBackground()
.ignoresSafeArea()
if isLoading { if isLoading {
ProgressView() ProgressView()
@@ -71,7 +70,6 @@ struct ManageUsersView: View {
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Residences.manageUsers) .navigationTitle(L10n.Residences.manageUsers)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {

View File

@@ -60,130 +60,185 @@ struct ResidenceFormView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
Form { ZStack {
Section { WarmGradientBackground()
TextField(L10n.Residences.propertyName, text: $name)
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) .focused($focusedField, equals: .name)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField)
if !nameError.isEmpty { OrganicFormPicker(
Text(nameError) label: L10n.Residences.propertyType,
.font(.caption) selection: $selectedPropertyType,
.foregroundColor(Color.appError) options: residenceTypes,
} optionLabel: { $0.name },
placeholder: L10n.Residences.selectType
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) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
} header: {
Text(L10n.Residences.propertyDetails)
} footer: {
Text(L10n.Residences.requiredName)
.font(.caption)
.foregroundColor(Color.appError)
} }
.listRowBackground(Color.appBackgroundSecondary) }
.padding(.top, 8)
Section { // Address Section
TextField(L10n.Residences.streetAddress, text: $streetAddress) 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) .focused($focusedField, equals: .streetAddress)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField)
TextField(L10n.Residences.apartmentUnit, text: $apartmentUnit) OrganicFormTextField(
label: L10n.Residences.apartmentUnit,
placeholder: "Apt 4B",
text: $apartmentUnit
)
.focused($focusedField, equals: .apartmentUnit) .focused($focusedField, equals: .apartmentUnit)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField)
TextField(L10n.Residences.city, text: $city) HStack(spacing: 12) {
OrganicFormTextField(
label: L10n.Residences.city,
placeholder: "City",
text: $city
)
.focused($focusedField, equals: .city) .focused($focusedField, equals: .city)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField)
TextField(L10n.Residences.stateProvince, text: $stateProvince) OrganicFormTextField(
label: L10n.Residences.stateProvince,
placeholder: "State",
text: $stateProvince
)
.focused($focusedField, equals: .stateProvince) .focused($focusedField, equals: .stateProvince)
.frame(maxWidth: 120)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField)
}
TextField(L10n.Residences.postalCode, text: $postalCode) HStack(spacing: 12) {
OrganicFormTextField(
label: L10n.Residences.postalCode,
placeholder: "12345",
text: $postalCode
)
.focused($focusedField, equals: .postalCode) .focused($focusedField, equals: .postalCode)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField)
TextField(L10n.Residences.country, text: $country) OrganicFormTextField(
label: L10n.Residences.country,
placeholder: "USA",
text: $country
)
.focused($focusedField, equals: .country) .focused($focusedField, equals: .country)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
} header: {
Text(L10n.Residences.address)
} }
.listRowBackground(Color.appBackgroundSecondary) }
}
Section(header: Text(L10n.Residences.propertyFeatures)) { // Property Features Section
HStack { OrganicFormSection(title: L10n.Residences.propertyFeatures, icon: "square.grid.2x2.fill") {
Text(L10n.Residences.bedrooms) VStack(spacing: 16) {
Spacer() HStack(spacing: 12) {
TextField("0", text: $bedrooms) OrganicFormTextField(
.keyboardType(.numberPad) label: L10n.Residences.bedrooms,
.multilineTextAlignment(.trailing) placeholder: "0",
.frame(width: 60) text: $bedrooms,
keyboardType: .numberPad
)
.focused($focusedField, equals: .bedrooms) .focused($focusedField, equals: .bedrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField)
}
HStack { OrganicFormTextField(
Text(L10n.Residences.bathrooms) label: L10n.Residences.bathrooms,
Spacer() placeholder: "0.0",
TextField("0.0", text: $bathrooms) text: $bathrooms,
.keyboardType(.decimalPad) keyboardType: .decimalPad
.multilineTextAlignment(.trailing) )
.frame(width: 60)
.focused($focusedField, equals: .bathrooms) .focused($focusedField, equals: .bathrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField)
} }
TextField(L10n.Residences.squareFootage, text: $squareFootage) HStack(spacing: 12) {
.keyboardType(.numberPad) OrganicFormTextField(
label: L10n.Residences.squareFootage,
placeholder: "sq ft",
text: $squareFootage,
keyboardType: .numberPad
)
.focused($focusedField, equals: .squareFootage) .focused($focusedField, equals: .squareFootage)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField)
TextField(L10n.Residences.lotSize, text: $lotSize) OrganicFormTextField(
.keyboardType(.decimalPad) label: L10n.Residences.lotSize,
placeholder: "acres",
text: $lotSize,
keyboardType: .decimalPad
)
.focused($focusedField, equals: .lotSize) .focused($focusedField, equals: .lotSize)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField)
}
TextField(L10n.Residences.yearBuilt, text: $yearBuilt) OrganicFormTextField(
.keyboardType(.numberPad) label: L10n.Residences.yearBuilt,
placeholder: "2020",
text: $yearBuilt,
keyboardType: .numberPad
)
.focused($focusedField, equals: .yearBuilt) .focused($focusedField, equals: .yearBuilt)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField)
} }
.listRowBackground(Color.appBackgroundSecondary) }
.keyboardDismissToolbar()
Section(header: Text(L10n.Residences.additionalDetails)) { // Additional Details Section
TextField(L10n.Residences.description, text: $description, axis: .vertical) OrganicFormSection(title: L10n.Residences.additionalDetails, icon: "text.alignleft") {
.lineLimit(3...6) VStack(spacing: 16) {
OrganicFormTextArea(
label: L10n.Residences.description,
placeholder: "Add notes about your property...",
text: $description
)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField)
.keyboardDismissToolbar()
Toggle(L10n.Residences.primaryResidence, isOn: $isPrimary) OrganicFormToggle(
label: L10n.Residences.primaryResidence,
isOn: $isPrimary,
icon: "star.fill"
)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
} }
.listRowBackground(Color.appBackgroundSecondary) }
// Users section (edit mode only, owner only) // Users Section (edit mode only, owner only)
if isEditMode && isCurrentUserOwner { if isEditMode && isCurrentUserOwner {
Section { OrganicFormSection(title: "Shared Users (\(users.count))", icon: "person.2.fill") {
VStack(spacing: 12) {
if isLoadingUsers { if isLoadingUsers {
HStack { HStack {
Spacer() Spacer()
ProgressView() ProgressView()
Spacer() Spacer()
} }
.padding(.vertical, 20)
} else if users.isEmpty { } else if users.isEmpty {
Text("No shared users") Text("No shared users")
.foregroundColor(.secondary) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.padding(.vertical, 12)
} else { } else {
ForEach(users, id: \.id) { user in ForEach(users, id: \.id) { user in
UserRow( OrganicUserRow(
user: user, user: user,
isOwner: user.id == existingResidence?.ownerId, isOwner: user.id == existingResidence?.ownerId,
onRemove: { onRemove: {
@@ -193,39 +248,68 @@ struct ResidenceFormView: View {
) )
} }
} }
} header: {
Text("Shared Users (\(users.count))") Text("Use the share button to invite others")
} footer: { .font(.system(size: 12, weight: .medium))
Text("Users with access to this residence. Use the share button to invite others.") .foregroundColor(Color.appTextSecondary)
}
} }
.listRowBackground(Color.appBackgroundSecondary)
} }
// Error Message
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
Section { HStack(spacing: 10) {
Text(errorMessage) Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
.font(.caption) Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
} }
.listRowBackground(Color.appBackgroundSecondary) .padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal, 16)
} }
Spacer()
.frame(height: 40)
}
.padding(.horizontal, 16)
}
.keyboardDismissToolbar()
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(isEditMode ? L10n.Residences.editTitle : L10n.Residences.addTitle) .navigationTitle(isEditMode ? L10n.Residences.editTitle : L10n.Residences.addTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button(L10n.Common.cancel) { Button(action: { isPresented = false }) {
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) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.formCancelButton)
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(L10n.Common.save) { Button(action: submitForm) {
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) .disabled(!canSave || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.saveButton) .accessibilityIdentifier(AccessibilityIdentifiers.Residence.saveButton)
@@ -255,25 +339,17 @@ struct ResidenceFormView: View {
Text("Are you sure you want to remove \(user.username) from this residence?") Text("Are you sure you want to remove \(user.username) from this residence?")
} }
} }
.handleErrors(
error: viewModel.errorMessage,
onRetry: { submitForm() }
)
} }
} }
private func loadResidenceTypes() { private func loadResidenceTypes() {
Task { 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) _ = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
} }
} }
private func initializeForm() { private func initializeForm() {
if let residence = existingResidence { if let residence = existingResidence {
// Edit mode - populate fields from existing residence
name = residence.name name = residence.name
streetAddress = residence.streetAddress ?? "" streetAddress = residence.streetAddress ?? ""
apartmentUnit = residence.apartmentUnit ?? "" apartmentUnit = residence.apartmentUnit ?? ""
@@ -289,12 +365,10 @@ struct ResidenceFormView: View {
description = residence.description_ ?? "" description = residence.description_ ?? ""
isPrimary = residence.isPrimary isPrimary = residence.isPrimary
// Set the selected property type
if let propertyTypeId = residence.propertyTypeId { if let propertyTypeId = residence.propertyTypeId {
selectedPropertyType = residenceTypes.first { $0.id == Int32(propertyTypeId) } selectedPropertyType = residenceTypes.first { $0.id == Int32(propertyTypeId) }
} }
} }
// In add mode, leave selectedPropertyType as nil to force user to select
} }
private func validateForm() -> Bool { private func validateForm() -> Bool {
@@ -313,7 +387,6 @@ struct ResidenceFormView: View {
private func submitForm() { private func submitForm() {
guard validateForm() else { return } guard validateForm() else { return }
// Convert optional numeric fields to Kotlin types
let bedroomsValue: KotlinInt? = { let bedroomsValue: KotlinInt? = {
guard !bedrooms.isEmpty, let value = Int32(bedrooms) else { return nil } guard !bedrooms.isEmpty, let value = Int32(bedrooms) else { return nil }
return KotlinInt(int: value) return KotlinInt(int: value)
@@ -335,7 +408,6 @@ struct ResidenceFormView: View {
return KotlinInt(int: value) return KotlinInt(int: value)
}() }()
// Convert propertyType to KotlinInt if it exists
let propertyTypeValue: KotlinInt? = { let propertyTypeValue: KotlinInt? = {
guard let type = selectedPropertyType else { return nil } guard let type = selectedPropertyType else { return nil }
return KotlinInt(int: Int32(type.id)) return KotlinInt(int: Int32(type.id))
@@ -362,7 +434,6 @@ struct ResidenceFormView: View {
) )
if let residence = existingResidence { if let residence = existingResidence {
// Edit mode
viewModel.updateResidence(id: residence.id, request: request) { success in viewModel.updateResidence(id: residence.id, request: request) { success in
if success { if success {
onSuccess?() onSuccess?()
@@ -370,10 +441,8 @@ struct ResidenceFormView: View {
} }
} }
} else { } else {
// Add mode
viewModel.createResidence(request: request) { success in viewModel.createResidence(request: request) { success in
if success { if success {
// Track residence created
PostHogAnalytics.shared.capture(AnalyticsEvents.residenceCreated, properties: [ PostHogAnalytics.shared.capture(AnalyticsEvents.residenceCreated, properties: [
"residence_type": selectedPropertyType?.name ?? "unknown" "residence_type": selectedPropertyType?.name ?? "unknown"
]) ])
@@ -397,7 +466,6 @@ struct ResidenceFormView: View {
await MainActor.run { await MainActor.run {
if let successResult = result as? ApiResultSuccess<NSArray>, if let successResult = result as? ApiResultSuccess<NSArray>,
let responseData = successResult.data as? [ResidenceUserResponse] { let responseData = successResult.data as? [ResidenceUserResponse] {
// Filter out the owner from the list
self.users = responseData.filter { $0.id != residence.ownerId } self.users = responseData.filter { $0.id != residence.ownerId }
} }
self.isLoadingUsers = false 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<Content: View>: 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<T: Hashable>: 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 user: ResidenceUserResponse
let isOwner: Bool let isOwner: Bool
let onRemove: () -> Void let onRemove: () -> Void
var body: some View { var body: some View {
HStack { HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) { ZStack {
HStack { 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) Text(user.username)
.font(.body) .font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
if isOwner { if isOwner {
Text("Owner") Text("Owner")
.font(.caption) .font(.system(size: 10, weight: .bold))
.foregroundColor(.white) .foregroundColor(Color.appTextOnPrimary)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(Color.appPrimary) .background(Color.appPrimary)
.clipShape(Capsule()) .clipShape(Capsule())
} }
} }
if !user.email.isEmpty { if !user.email.isEmpty {
Text(user.email) Text(user.email)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary) .foregroundColor(Color.appTextSecondary)
}
let fullName = [user.firstName, user.lastName]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: " ")
if !fullName.isEmpty {
Text(fullName)
.font(.caption)
.foregroundColor(.secondary)
} }
} }
@@ -477,12 +741,18 @@ private struct UserRow: View {
if !isOwner { if !isOwner {
Button(action: onRemove) { Button(action: onRemove) {
Image(systemName: "trash") Image(systemName: "trash")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
.padding(8)
.background(Color.appError.opacity(0.1))
.clipShape(Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
.padding(.vertical, 4) .padding(12)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} }
} }

View File

@@ -139,7 +139,7 @@ struct FeatureComparisonView: View {
.padding(.bottom, AppSpacing.xl) .padding(.bottom, AppSpacing.xl)
} }
} }
.background(Color.appBackgroundPrimary) .background(WarmGradientBackground())
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {

View File

@@ -11,15 +11,14 @@ struct UpgradeFeatureView: View {
@State private var selectedProduct: Product? @State private var selectedProduct: Product?
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var showSuccessAlert = false @State private var showSuccessAlert = false
@State private var isAnimating = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKit = StoreKitManager.shared @StateObject private var storeKit = StoreKitManager.shared
// Look up trigger data from cache
private var triggerData: UpgradeTriggerData? { private var triggerData: UpgradeTriggerData? {
subscriptionCache.upgradeTriggers[triggerKey] subscriptionCache.upgradeTriggers[triggerKey]
} }
// Fallback values if trigger not found
private var title: String { private var title: String {
triggerData?.title ?? "Upgrade Required" triggerData?.title ?? "Upgrade Required"
} }
@@ -33,55 +32,90 @@ struct UpgradeFeatureView: View {
} }
var body: some View { var body: some View {
ScrollView { ScrollView(showsIndicators: false) {
VStack(spacing: AppSpacing.xl) { VStack(spacing: OrganicSpacing.comfortable) {
// Icon // Hero Section
Image(systemName: "star.circle.fill") VStack(spacing: OrganicSpacing.comfortable) {
.font(.system(size: 60)) ZStack {
.foregroundStyle(Color.appAccent.gradient) Circle()
.padding(.top, AppSpacing.xl) .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 ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appAccent, Color.appAccent.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Image(systemName: "star.fill")
.font(.system(size: 36, weight: .medium))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
.padding(.top, OrganicSpacing.comfortable)
VStack(spacing: 8) {
Text(title) Text(title)
.font(.title2.weight(.bold)) .font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal)
// Message
Text(message) Text(message)
.font(.body) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal) .padding(.horizontal)
}
}
// Pro Features Preview - Dynamic content or fallback // Features Card
Group { VStack(spacing: 16) {
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty { if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
PromoContentView(content: promoContent) PromoContentView(content: promoContent)
.padding()
} else { } else {
// Fallback to static features if no promo content VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: AppSpacing.md) { OrganicUpgradeFeatureRow(icon: "house.fill", text: "Unlimited properties")
FeatureRow(icon: "house.fill", text: "Unlimited properties") OrganicUpgradeFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks") OrganicUpgradeFeatureRow(icon: "person.2.fill", text: "Contractor management")
FeatureRow(icon: "person.2.fill", text: "Contractor management") OrganicUpgradeFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
FeatureRow(icon: "doc.fill", text: "Document & warranty storage")
}
.padding()
} }
} }
.background(Color.appBackgroundSecondary) }
.cornerRadius(AppRadius.lg) .padding(OrganicSpacing.cozy)
.padding(.horizontal) .background(OrganicUpgradeCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.naturalShadow(.medium)
.padding(.horizontal, 16)
// Subscription Products // Subscription Products
VStack(spacing: 12) {
if storeKit.isLoading { if storeKit.isLoading {
ProgressView() ProgressView()
.tint(Color.appPrimary) .tint(Color.appPrimary)
.padding() .padding()
} else if !storeKit.products.isEmpty { } else if !storeKit.products.isEmpty {
VStack(spacing: AppSpacing.md) {
ForEach(storeKit.products, id: \.id) { product in ForEach(storeKit.products, id: \.id) { product in
SubscriptionProductButton( SubscriptionProductButton(
product: product, product: product,
@@ -93,69 +127,64 @@ struct UpgradeFeatureView: View {
} }
) )
} }
}
.padding(.horizontal)
} else { } else {
// Fallback upgrade button if products fail to load
Button(action: { Button(action: {
Task { await storeKit.loadProducts() } Task { await storeKit.loadProducts() }
}) { }) {
HStack { HStack(spacing: 8) {
if isProcessing { Image(systemName: "arrow.clockwise")
ProgressView()
.tint(Color.appTextOnPrimary)
} else {
Text("Retry Loading Products") Text("Retry Loading Products")
.fontWeight(.semibold) .font(.system(size: 16, weight: .semibold))
}
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.padding()
.background(Color.appPrimary) .background(Color.appPrimary)
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
.disabled(isProcessing)
.padding(.horizontal)
} }
}
.padding(.horizontal, 16)
// Error Message // Error Message
if let error = errorMessage { if let error = errorMessage {
HStack { HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Text(error) Text(error)
.font(.subheadline) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Spacer()
} }
.padding() .padding(16)
.background(Color.appError.opacity(0.1)) .background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal) .padding(.horizontal, 16)
} }
// Compare Plans // Links
VStack(spacing: 12) {
Button(action: { Button(action: {
showFeatureComparison = true showFeatureComparison = true
}) { }) {
Text("Compare Free vs Pro") Text("Compare Free vs Pro")
.font(.subheadline) .font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
// Restore Purchases
Button(action: { Button(action: {
handleRestore() handleRestore()
}) { }) {
Text("Restore Purchases") Text("Restore Purchases")
.font(.caption) .font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.padding(.bottom, AppSpacing.xxxl) }
.padding(.bottom, OrganicSpacing.airy)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.appBackgroundPrimary) .background(WarmGradientBackground())
.sheet(isPresented: $showFeatureComparison) { .sheet(isPresented: $showFeatureComparison) {
FeatureComparisonView(isPresented: $showFeatureComparison) FeatureComparisonView(isPresented: $showFeatureComparison)
} }
@@ -165,10 +194,12 @@ struct UpgradeFeatureView: View {
Text("You now have full access to all Pro features!") Text("You now have full access to all Pro features!")
} }
.task { .task {
// Refresh subscription cache to get latest upgrade triggers
subscriptionCache.refreshFromCache() subscriptionCache.refreshFromCache()
await storeKit.loadProducts() await storeKit.loadProducts()
} }
.onAppear {
isAnimating = true
}
} }
private func handlePurchase(_ product: Product) { private func handlePurchase(_ product: Product) {
@@ -183,7 +214,6 @@ struct UpgradeFeatureView: View {
isProcessing = false isProcessing = false
if transaction != nil { if transaction != nil {
// Purchase successful
showSuccessAlert = true 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 { #Preview {
UpgradeFeatureView( UpgradeFeatureView(
triggerKey: "view_contractors", triggerKey: "view_contractors",

View File

@@ -21,30 +21,35 @@ struct PromoContentView: View {
case .title(let text): case .title(let text):
Text(text) Text(text)
.font(.title3.bold()) .font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
case .body(let text): case .body(let text):
Text(text) Text(text)
.font(.subheadline) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
case .checkItem(let text): case .checkItem(let text):
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 10) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 24, height: 24)
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold)) .font(.system(size: 12, weight: .bold))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
}
Text(text) Text(text)
.font(.subheadline) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Spacer() Spacer()
} }
case .italic(let text): case .italic(let text):
Text(text) Text(text)
.font(.caption) .font(.system(size: 12, weight: .medium))
.italic() .italic()
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -78,15 +83,12 @@ struct PromoContentView: View {
let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces) let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces)
result.append(.checkItem(text)) result.append(.checkItem(text))
} else if trimmed.contains("<b>") && trimmed.contains("</b>") { } else if trimmed.contains("<b>") && trimmed.contains("</b>") {
// Title line with emoji
let cleaned = trimmed let cleaned = trimmed
.replacingOccurrences(of: "<b>", with: "") .replacingOccurrences(of: "<b>", with: "")
.replacingOccurrences(of: "</b>", with: "") .replacingOccurrences(of: "</b>", with: "")
// Check if starts with emoji
if let firstScalar = cleaned.unicodeScalars.first, if let firstScalar = cleaned.unicodeScalars.first,
firstScalar.properties.isEmoji && !firstScalar.properties.isASCIIHexDigit { firstScalar.properties.isEmoji && !firstScalar.properties.isASCIIHexDigit {
// Split emoji and title
let parts = cleaned.split(separator: " ", maxSplits: 1) let parts = cleaned.split(separator: " ", maxSplits: 1)
if parts.count == 2 { if parts.count == 2 {
result.append(.emoji(String(parts[0]))) result.append(.emoji(String(parts[0])))
@@ -104,7 +106,6 @@ struct PromoContentView: View {
result.append(.italic(text)) result.append(.italic(text))
} else if trimmed.first?.unicodeScalars.first?.properties.isEmoji == true && } else if trimmed.first?.unicodeScalars.first?.properties.isEmoji == true &&
trimmed.count <= 2 { trimmed.count <= 2 {
// Standalone emoji
result.append(.emoji(trimmed)) result.append(.emoji(trimmed))
} else { } else {
result.append(.body(trimmed)) result.append(.body(trimmed))
@@ -126,6 +127,7 @@ struct UpgradePromptView: View {
@State private var selectedProduct: Product? @State private var selectedProduct: Product?
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var showSuccessAlert = false @State private var showSuccessAlert = false
@State private var isAnimating = false
var triggerData: UpgradeTriggerData? { var triggerData: UpgradeTriggerData? {
subscriptionCache.upgradeTriggers[triggerKey] subscriptionCache.upgradeTriggers[triggerKey]
@@ -133,57 +135,95 @@ struct UpgradePromptView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ZStack {
VStack(spacing: AppSpacing.xl) { WarmGradientBackground()
// Icon
Image(systemName: "star.circle.fill")
.font(.system(size: 60))
.foregroundStyle(Color.appAccent.gradient)
.padding(.top, AppSpacing.xl)
// Title 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
)
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appAccent, Color.appAccent.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Image(systemName: "star.fill")
.font(.system(size: 36, weight: .medium))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
.padding(.top, OrganicSpacing.comfortable)
VStack(spacing: 8) {
Text(triggerData?.title ?? "Upgrade to Pro") Text(triggerData?.title ?? "Upgrade to Pro")
.font(.title2.weight(.bold)) .font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal)
// Message
Text(triggerData?.message ?? "Unlock unlimited access to all features") Text(triggerData?.message ?? "Unlock unlimited access to all features")
.font(.body) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal) .padding(.horizontal)
}
}
// Pro Features Preview - Dynamic content or fallback // Features Card
Group { VStack(spacing: 16) {
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty { if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
PromoContentView(content: promoContent) PromoContentView(content: promoContent)
.padding()
} else { } else {
// Fallback to static features if no promo content VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: AppSpacing.md) { OrganicFeatureRow(icon: "house.fill", text: "Unlimited properties")
FeatureRow(icon: "house.fill", text: "Unlimited properties") OrganicFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks") OrganicFeatureRow(icon: "person.2.fill", text: "Contractor management")
FeatureRow(icon: "person.2.fill", text: "Contractor management") OrganicFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
FeatureRow(icon: "doc.fill", text: "Document & warranty storage")
}
.padding()
} }
} }
.background(Color.appBackgroundSecondary) }
.cornerRadius(AppRadius.lg) .padding(OrganicSpacing.cozy)
.padding(.horizontal) .background(OrganicCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.naturalShadow(.medium)
.padding(.horizontal, 16)
// Subscription Products // Subscription Products
VStack(spacing: 12) {
if storeKit.isLoading { if storeKit.isLoading {
ProgressView() ProgressView()
.tint(Color.appPrimary) .tint(Color.appPrimary)
.padding() .padding()
} else if !storeKit.products.isEmpty { } else if !storeKit.products.isEmpty {
VStack(spacing: AppSpacing.md) {
ForEach(storeKit.products, id: \.id) { product in ForEach(storeKit.products, id: \.id) { product in
SubscriptionProductButton( OrganicSubscriptionButton(
product: product, product: product,
isSelected: selectedProduct?.id == product.id, isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing, isProcessing: isProcessing,
@@ -193,73 +233,73 @@ struct UpgradePromptView: View {
} }
) )
} }
}
.padding(.horizontal)
} else { } else {
// Fallback upgrade button if products fail to load
Button(action: { Button(action: {
Task { await storeKit.loadProducts() } Task { await storeKit.loadProducts() }
}) { }) {
HStack { HStack(spacing: 8) {
if isProcessing { Image(systemName: "arrow.clockwise")
ProgressView()
.tint(Color.appTextOnPrimary)
} else {
Text("Retry Loading Products") Text("Retry Loading Products")
.fontWeight(.semibold) .font(.system(size: 16, weight: .semibold))
}
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.padding()
.background(Color.appPrimary) .background(Color.appPrimary)
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
.disabled(isProcessing)
.padding(.horizontal)
} }
}
.padding(.horizontal, 16)
// Error Message // Error Message
if let error = errorMessage { if let error = errorMessage {
HStack { HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Text(error) Text(error)
.font(.subheadline) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Spacer()
} }
.padding() .padding(16)
.background(Color.appError.opacity(0.1)) .background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal) .padding(.horizontal, 16)
} }
// Compare Plans // Links
VStack(spacing: 12) {
Button(action: { Button(action: {
showFeatureComparison = true showFeatureComparison = true
}) { }) {
Text("Compare Free vs Pro") Text("Compare Free vs Pro")
.font(.subheadline) .font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
// Restore Purchases
Button(action: { Button(action: {
handleRestore() handleRestore()
}) { }) {
Text("Restore Purchases") Text("Restore Purchases")
.font(.caption) .font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.padding(.bottom, AppSpacing.xl) }
.padding(.bottom, OrganicSpacing.airy)
}
} }
} }
.background(Color.appBackgroundPrimary)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { Button(action: { isPresented = false }) {
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!") Text("You now have full access to all Pro features!")
} }
.task { .task {
// Refresh subscription cache to get latest upgrade triggers
subscriptionCache.refreshFromCache() subscriptionCache.refreshFromCache()
await storeKit.loadProducts() await storeKit.loadProducts()
} }
.onAppear {
isAnimating = true
}
} }
} }
@@ -293,7 +335,6 @@ struct UpgradePromptView: View {
isProcessing = false isProcessing = false
if transaction != nil { if transaction != nil {
// Purchase successful
showSuccessAlert = true 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 { struct SubscriptionProductButton: View {
let product: Product let product: Product
let isSelected: Bool let isSelected: Bool
@@ -344,42 +523,13 @@ struct SubscriptionProductButton: View {
} }
var body: some View { var body: some View {
Button(action: onSelect) { OrganicSubscriptionButton(
HStack { product: product,
VStack(alignment: .leading, spacing: 4) { isSelected: isSelected,
Text(product.displayName) isProcessing: isProcessing,
.font(.headline) onSelect: onSelect
.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)
}
} }
struct FeatureRow: View { struct FeatureRow: View {
@@ -387,17 +537,7 @@ struct FeatureRow: View {
let text: String let text: String
var body: some View { var body: some View {
HStack(spacing: AppSpacing.md) { OrganicFeatureRow(icon: icon, text: text)
Image(systemName: icon)
.foregroundColor(Color.appPrimary)
.frame(width: 24)
Text(text)
.font(.body)
.foregroundColor(Color.appTextPrimary)
Spacer()
}
} }
} }

View File

@@ -5,25 +5,34 @@ struct ErrorView: View {
let retryAction: () -> Void let retryAction: () -> Void
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: OrganicSpacing.cozy) {
ZStack {
Circle()
.fill(Color.appError.opacity(0.1))
.frame(width: 100, height: 100)
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.system(size: 64)) .font(.system(size: 44, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
}
Text("Error: \(message)") Text("Error: \(message)")
.foregroundColor(Color.appError) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button(action: retryAction) { Button(action: retryAction) {
Text("Retry") Text("Retry")
.font(.system(size: 16, weight: .semibold))
.padding(.horizontal, 32) .padding(.horizontal, 32)
.padding(.vertical, 12) .padding(.vertical, 14)
.background(Color.appPrimary) .background(Color.appPrimary)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.cornerRadius(8) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.naturalShadow(.subtle)
} }
} }
.padding() .padding(OrganicSpacing.comfortable)
} }
} }

View File

@@ -7,23 +7,23 @@ struct StatView: View {
var color: Color = Color.appPrimary var color: Color = Color.appPrimary
var body: some View { var body: some View {
VStack(spacing: AppSpacing.sm) { VStack(spacing: OrganicSpacing.compact) {
ZStack { ZStack {
Circle() Circle()
.fill(color.opacity(0.1)) .fill(color.opacity(0.1))
.frame(width: 48, height: 48) .frame(width: 52, height: 52)
if icon == "house_outline" { if icon == "house_outline" {
Image("house_outline") Image("house_outline")
.resizable() .resizable()
.frame(width: 22, height: 22) .frame(width: 24, height: 24)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.background(content: { .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)) .fill(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
.frame(width: 22, height: 22) .frame(width: 24, height: 24)
.shadow(color: Color.appPrimary.opacity(0.3), radius: 6, y: 3)
}) })
.naturalShadow(.subtle)
} else { } else {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 22, weight: .semibold)) .font(.system(size: 22, weight: .semibold))
@@ -32,12 +32,11 @@ struct StatView: View {
} }
Text(value) Text(value)
.font(.title2.weight(.semibold)) .font(.system(size: 22, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text(label) Text(label)
.font(.footnote.weight(.medium)) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }

View File

@@ -2,18 +2,23 @@ import SwiftUI
struct EmptyResidencesView: View { struct EmptyResidencesView: View {
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: OrganicSpacing.cozy) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.08))
.frame(width: 120, height: 120)
Image(systemName: "house") Image(systemName: "house")
.font(.system(size: 80)) .font(.system(size: 56, weight: .medium))
.foregroundColor(Color.appPrimary.opacity(0.6)) .foregroundColor(Color.appPrimary.opacity(0.6))
}
Text("No properties yet") Text("No properties yet")
.font(.title2) .font(.system(size: 20, weight: .semibold, design: .rounded))
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text("Add your first property to get started!") Text("Add your first property to get started!")
.font(.body) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }

View File

@@ -6,18 +6,23 @@ struct SummaryStatView: View {
let label: String let label: String
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: OrganicSpacing.compact) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 44, height: 44)
Image(systemName: icon) Image(systemName: icon)
.font(.title3) .font(.system(size: 18, weight: .semibold))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
}
Text(value) Text(value)
.font(.title2) .font(.system(size: 20, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text(label) Text(label)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }

View File

@@ -27,7 +27,7 @@ struct CompletionCardView: View {
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(Color.appAccent.opacity(0.1)) .background(Color.appAccent.opacity(0.1))
.cornerRadius(6) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
} }
} }
@@ -88,13 +88,13 @@ struct CompletionCardView: View {
.padding(.vertical, 8) .padding(.vertical, 8)
.background(Color.appPrimary.opacity(0.1)) .background(Color.appPrimary.opacity(0.1))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
.cornerRadius(8) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
} }
} }
} }
.padding(12) .padding(14)
.background(Color.appBackgroundSecondary.opacity(0.5)) .background(Color.appBackgroundSecondary.opacity(0.5))
.cornerRadius(8) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.sheet(isPresented: $showPhotoSheet) { .sheet(isPresented: $showPhotoSheet) {
PhotoViewerSheet(images: completion.images) PhotoViewerSheet(images: completion.images)
} }

View File

@@ -38,13 +38,12 @@ struct DynamicTaskColumnView: View {
Spacer() Spacer()
Text("\(column.count)") Text("\(column.count)")
.font(.caption) .font(.system(size: 12, weight: .semibold))
.fontWeight(.semibold)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.padding(.horizontal, 8) .padding(.horizontal, 10)
.padding(.vertical, 4) .padding(.vertical, 5)
.background(columnColor) .background(columnColor)
.cornerRadius(12) .clipShape(Capsule())
} }
if column.tasks.isEmpty { if column.tasks.isEmpty {

View File

@@ -2,19 +2,26 @@ import SwiftUI
struct EmptyTasksView: View { struct EmptyTasksView: View {
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: OrganicSpacing.cozy) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.08))
.frame(width: 80, height: 80)
Image(systemName: "checkmark.circle") Image(systemName: "checkmark.circle")
.font(.system(size: 48)) .font(.system(size: 36, weight: .medium))
.foregroundColor(Color.appTextSecondary.opacity(0.5)) .foregroundColor(Color.appPrimary.opacity(0.5))
}
Text("No tasks yet") Text("No tasks yet")
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(32) .padding(OrganicSpacing.spacious)
.background(Color.appBackgroundSecondary) .background(Color.appBackgroundSecondary)
.cornerRadius(12) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(.subtle)
} }
} }

View File

@@ -18,12 +18,9 @@ struct AllTasksView: View {
@State private var selectedTaskForCancel: TaskResponse? @State private var selectedTaskForCancel: TaskResponse?
@State private var showCancelConfirmation = false @State private var showCancelConfirmation = false
// Deep link task ID to open (from push notification)
@State private var pendingTaskId: Int32? @State private var pendingTaskId: Int32?
// Column index to scroll to (for deep link navigation)
@State private var scrollToColumnIndex: Int? @State private var scrollToColumnIndex: Int?
// Use ViewModel's computed properties
private var totalTaskCount: Int { taskViewModel.totalTaskCount } private var totalTaskCount: Int { taskViewModel.totalTaskCount }
private var hasNoTasks: Bool { taskViewModel.hasNoTasks } private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
private var hasTasks: Bool { taskViewModel.hasTasks } private var hasTasks: Bool { taskViewModel.hasTasks }
@@ -109,12 +106,10 @@ struct AllTasksView: View {
.onAppear { .onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.taskScreenShown) PostHogAnalytics.shared.screen(AnalyticsEvents.taskScreenShown)
// Check for pending navigation from push notification (app launched from notification)
if let taskId = PushNotificationManager.shared.pendingNavigationTaskId { if let taskId = PushNotificationManager.shared.pendingNavigationTaskId {
pendingTaskId = Int32(taskId) pendingTaskId = Int32(taskId)
} }
// Check if widget completed a task - force refresh if dirty
if WidgetDataManager.shared.areTasksDirty() { if WidgetDataManager.shared.areTasksDirty() {
WidgetDataManager.shared.clearDirtyFlag() WidgetDataManager.shared.clearDirtyFlag()
loadAllTasks(forceRefresh: true) loadAllTasks(forceRefresh: true)
@@ -123,43 +118,29 @@ struct AllTasksView: View {
} }
residenceViewModel.loadMyResidences() residenceViewModel.loadMyResidences()
} }
// Handle push notification deep links
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in .onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in
print("📬 AllTasksView received .navigateToTask notification")
if let userInfo = notification.userInfo, if let userInfo = notification.userInfo,
let taskId = userInfo["taskId"] as? Int { let taskId = userInfo["taskId"] as? Int {
print("📬 Setting pendingTaskId to \(taskId)")
pendingTaskId = Int32(taskId) pendingTaskId = Int32(taskId)
// If tasks are already loaded, try to navigate immediately
if let response = tasksResponse { if let response = tasksResponse {
print("📬 Tasks already loaded, attempting immediate navigation")
navigateToTaskInKanban(taskId: Int32(taskId), response: response) 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 .onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { notification in
print("📬 AllTasksView received .navigateToEditTask notification")
if let userInfo = notification.userInfo, if let userInfo = notification.userInfo,
let taskId = userInfo["taskId"] as? Int { let taskId = userInfo["taskId"] as? Int {
print("📬 Setting pendingTaskId to \(taskId)")
pendingTaskId = Int32(taskId) pendingTaskId = Int32(taskId)
// If tasks are already loaded, try to navigate immediately
if let response = tasksResponse { if let response = tasksResponse {
print("📬 Tasks already loaded, attempting immediate navigation")
navigateToTaskInKanban(taskId: Int32(taskId), response: response) 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 .onChange(of: tasksResponse) { response in
print("📬 tasksResponse changed, pendingTaskId=\(pendingTaskId?.description ?? "nil")")
if let taskId = pendingTaskId, let response = response { if let taskId = pendingTaskId, let response = response {
navigateToTaskInKanban(taskId: taskId, 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 .onChange(of: scenePhase) { newPhase in
if newPhase == .active { if newPhase == .active {
if WidgetDataManager.shared.areTasksDirty() { if WidgetDataManager.shared.areTasksDirty() {
@@ -173,8 +154,7 @@ struct AllTasksView: View {
@ViewBuilder @ViewBuilder
private var mainContent: some View { private var mainContent: some View {
ZStack { ZStack {
Color.appBackgroundPrimary WarmGradientBackground()
.ignoresSafeArea()
if hasNoTasks && isLoadingTasks { if hasNoTasks && isLoadingTasks {
ProgressView() ProgressView()
@@ -184,55 +164,13 @@ struct AllTasksView: View {
} }
} else if let tasksResponse = tasksResponse { } else if let tasksResponse = tasksResponse {
if hasNoTasks { if hasNoTasks {
// Empty state with big button OrganicEmptyTasksView(
VStack(spacing: 24) { totalTaskCount: totalTaskCount,
Spacer() hasResidences: !(residenceViewModel.myResidences?.residences.isEmpty ?? true),
subscriptionCache: subscriptionCache,
Image(systemName: "checklist") showingUpgradePrompt: $showingUpgradePrompt,
.font(.system(size: 64)) showAddTask: $showAddTask
.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()
} else { } else {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) { 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 { if index == 0 && shouldShowSwipeHint {
SwipeHintView() SwipeHintView()
} }
@@ -300,7 +237,6 @@ struct AllTasksView: View {
withAnimation(.easeInOut(duration: 0.3)) { withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(columnIndex, anchor: .leading) proxy.scrollTo(columnIndex, anchor: .leading)
} }
// Clear after scrolling
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
scrollToColumnIndex = nil scrollToColumnIndex = nil
} }
@@ -310,35 +246,33 @@ struct AllTasksView: View {
} }
} }
} }
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Tasks.allTasks)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
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)
}
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true || isLoadingTasks)
Button(action: { Button(action: {
// Check if we should show upgrade prompt before adding
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") { if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
showingUpgradePrompt = true showingUpgradePrompt = true
} else { } else {
showAddTask = true showAddTask = true
} }
}) { }) {
Image(systemName: "plus") OrganicToolbarAddButton()
} }
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true) .disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton) .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 .onChange(of: taskViewModel.isLoading) { isLoading in
@@ -357,33 +291,157 @@ struct AllTasksView: View {
} }
private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) { 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() { for (index, column) in response.columns.enumerated() {
if column.tasks.contains(where: { $0.id == taskId }) { if column.tasks.contains(where: { $0.id == taskId }) {
print("📬 Found task in column \(index) '\(column.name)'")
// Clear pending
pendingTaskId = nil pendingTaskId = nil
PushNotificationManager.shared.clearPendingNavigation() PushNotificationManager.shared.clearPendingNavigation()
// Small delay to ensure view is ready, then scroll
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.scrollToColumnIndex = index self.scrollToColumnIndex = index
} }
return return
} }
} }
// Task not found
print("📬 Task with id=\(taskId) not found")
pendingTaskId = nil pendingTaskId = nil
PushNotificationManager.shared.clearPendingNavigation() 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 { extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCorner(radius: radius, corners: corners)) clipShape(RoundedCorner(radius: radius, corners: corners))
@@ -411,7 +469,6 @@ struct RoundedCorner: Shape {
} }
extension Array where Element == ResidenceResponse { extension Array where Element == ResidenceResponse {
/// Returns the array as-is (for API compatibility)
func toResidences() -> [ResidenceResponse] { func toResidences() -> [ResidenceResponse] {
return self return self
} }

View File

@@ -259,7 +259,7 @@ struct CompleteTaskView: View {
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary) .background(WarmGradientBackground())
.navigationTitle(L10n.Tasks.completeTask) .navigationTitle(L10n.Tasks.completeTask)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -389,30 +389,34 @@ struct ContractorPickerView: View {
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(L10n.Tasks.noneManual) Text(L10n.Tasks.noneManual)
.foregroundStyle(.primary) .foregroundColor(Color.appTextPrimary)
Text(L10n.Tasks.enterManually) Text(L10n.Tasks.enterManually)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundColor(Color.appTextSecondary)
} }
Spacer() Spacer()
if selectedContractor == nil { if selectedContractor == nil {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundStyle(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
} }
} }
.listRowBackground(Color.appBackgroundSecondary)
// Contractors list // Contractors list
if contractorViewModel.isLoading { if contractorViewModel.isLoading {
HStack { HStack {
Spacer() Spacer()
ProgressView() ProgressView()
.tint(Color.appPrimary)
Spacer() Spacer()
} }
.listRowBackground(Color.appBackgroundSecondary)
} else if let errorMessage = contractorViewModel.errorMessage { } else if let errorMessage = contractorViewModel.errorMessage {
Text(errorMessage) Text(errorMessage)
.foregroundStyle(Color.appError) .foregroundColor(Color.appError)
.font(.caption) .font(.caption)
.listRowBackground(Color.appBackgroundSecondary)
} else { } else {
ForEach(contractorViewModel.contractors, id: \.id) { contractor in ForEach(contractorViewModel.contractors, id: \.id) { contractor in
Button(action: { Button(action: {
@@ -422,12 +426,12 @@ struct ContractorPickerView: View {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(contractor.name) Text(contractor.name)
.foregroundStyle(.primary) .foregroundColor(Color.appTextPrimary)
if let company = contractor.company { if let company = contractor.company {
Text(company) Text(company)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundColor(Color.appTextSecondary)
} }
if let firstSpecialty = contractor.specialties.first { if let firstSpecialty = contractor.specialties.first {
@@ -437,7 +441,7 @@ struct ContractorPickerView: View {
Text(firstSpecialty.name) Text(firstSpecialty.name)
.font(.caption2) .font(.caption2)
} }
.foregroundStyle(.tertiary) .foregroundColor(Color.appTextSecondary.opacity(0.7))
} }
} }
@@ -445,13 +449,17 @@ struct ContractorPickerView: View {
if selectedContractor?.id == contractor.id { if selectedContractor?.id == contractor.id {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.foregroundStyle(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
} }
} }
.listRowBackground(Color.appBackgroundSecondary)
} }
} }
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(WarmGradientBackground())
.navigationTitle(L10n.Tasks.selectContractor) .navigationTitle(L10n.Tasks.selectContractor)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {

View File

@@ -74,8 +74,8 @@ struct TaskSuggestionsView: View {
} }
} }
.background(Color.appBackgroundSecondary) .background(Color.appBackgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) .naturalShadow(.medium)
} }
private func categoryColor(for categoryName: String) -> Color { private func categoryColor(for categoryName: String) -> Color {

View File

@@ -34,7 +34,7 @@ struct TaskTemplatesBrowserView: View {
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary) .background(WarmGradientBackground())
.searchable(text: $searchText, prompt: "Search templates...") .searchable(text: $searchText, prompt: "Search templates...")
.navigationTitle("Task Templates") .navigationTitle("Task Templates")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)

View File

@@ -9,121 +9,165 @@ struct VerifyEmailView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
Color.appBackgroundPrimary WarmGradientBackground()
.ignoresSafeArea()
ScrollView { ScrollView(showsIndicators: false) {
VStack(spacing: 24) { VStack(spacing: OrganicSpacing.spacious) {
Spacer().frame(height: 20) 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)
// Header
VStack(spacing: 12) {
Image(systemName: "envelope.badge.shield.half.filled") Image(systemName: "envelope.badge.shield.half.filled")
.font(.system(size: 60)) .font(.system(size: 48, weight: .medium))
.foregroundStyle(Color.appPrimary.gradient) .foregroundColor(Color.appPrimary)
.padding(.bottom, 8) }
VStack(spacing: 8) {
Text(L10n.Auth.verifyYourEmail) Text(L10n.Auth.verifyYourEmail)
.font(.title) .font(.system(size: 26, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text(L10n.Auth.verifyMustVerify) Text(L10n.Auth.verifyMustVerify)
.font(.subheadline) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal) .padding(.horizontal)
} }
}
// Info Card // Form Card
GroupBox { VStack(spacing: 20) {
// Info Banner
HStack(spacing: 12) { HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appAccent.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "exclamationmark.shield.fill") Image(systemName: "exclamationmark.shield.fill")
.font(.system(size: 18, weight: .medium))
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
.font(.title2) }
Text(L10n.Auth.verifyCheckInbox) Text(L10n.Auth.verifyCheckInbox)
.font(.subheadline) .font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.fontWeight(.semibold)
Spacer()
} }
.padding(.vertical, 4) .padding(16)
} .background(Color.appAccent.opacity(0.08))
.padding(.horizontal) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
// Code Input // Code Input
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 8) {
Text(L10n.Auth.verifyCodeLabel) Text(L10n.Auth.verifyCodeLabel.uppercased())
.font(.headline) .font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextSecondary)
.padding(.horizontal) .tracking(1.2)
TextField("000000", text: $viewModel.code) TextField("000000", text: $viewModel.code)
.font(.system(size: 32, weight: .semibold, design: .rounded)) .font(.system(size: 32, weight: .bold, design: .rounded))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.keyboardType(.numberPad) .keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.frame(height: 60)
.padding(.horizontal)
.focused($isFocused) .focused($isFocused)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verificationCodeField)
.keyboardDismissToolbar() .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 .onChange(of: viewModel.code) { _, newValue in
// Limit to 6 digits
if newValue.count > 6 { if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6)) viewModel.code = String(newValue.prefix(6))
} }
// Only allow numbers
viewModel.code = newValue.filter { $0.isNumber } viewModel.code = newValue.filter { $0.isNumber }
} }
Text(L10n.Auth.verifyCodeMustBe6) Text(L10n.Auth.verifyCodeMustBe6)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.padding(.horizontal)
} }
// Error Message // Error Message
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
ErrorMessageView(message: errorMessage, onDismiss: viewModel.clearError) HStack(spacing: 10) {
.padding(.horizontal) 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))
} }
// Verify Button // Verify Button
Button(action: { Button(action: {
viewModel.verifyEmail() viewModel.verifyEmail()
}) { }) {
HStack { HStack(spacing: 8) {
if viewModel.isLoading { if viewModel.isLoading {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white)) .progressViewStyle(CircularProgressViewStyle(tint: .white))
} else { } else {
Image(systemName: "checkmark.shield.fill") Image(systemName: "checkmark.shield.fill")
Text(L10n.Auth.verifyEmailButton) }
Text(viewModel.isLoading ? "Verifying..." : L10n.Auth.verifyEmailButton)
.font(.headline)
.fontWeight(.semibold) .fontWeight(.semibold)
} }
}
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 50) .frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background( .background(
viewModel.code.count == 6 && !viewModel.isLoading viewModel.code.count == 6 && !viewModel.isLoading
? Color.appPrimary ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: Color.gray.opacity(0.3) : 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
) )
.foregroundColor(Color.appTextOnPrimary)
.cornerRadius(12)
} }
.disabled(viewModel.code.count != 6 || viewModel.isLoading) .disabled(viewModel.code.count != 6 || viewModel.isLoading)
.padding(.horizontal)
Spacer().frame(height: 20)
// Help Text // Help Text
Text(L10n.Auth.verifyHelpText) Text(L10n.Auth.verifyHelpText)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 32) }
.padding(OrganicSpacing.cozy)
.background(OrganicVerifyEmailBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
} }
} }
} }
@@ -131,12 +175,17 @@ struct VerifyEmailView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
Button(action: onLogout) { Button(action: onLogout) {
HStack(spacing: 4) { HStack(spacing: 6) {
Image(systemName: "rectangle.portrait.and.arrow.right") Image(systemName: "rectangle.portrait.and.arrow.right")
.font(.system(size: 16)) .font(.system(size: 14, weight: .medium))
Text(L10n.Auth.logout) 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() 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)
} }
} }
} }