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,59 +34,58 @@ 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 }
) )
}
if let specialty = selectedSpecialty {
FilterChip(
title: specialty,
onRemove: { selectedSpecialty = nil }
)
}
} }
.padding(.horizontal, AppSpacing.md)
}
.padding(.vertical, AppSpacing.xs)
}
// Content - use filteredContractors for client-side filtering if let specialty = selectedSpecialty {
OrganicFilterChip(
title: specialty,
onRemove: { selectedSpecialty = nil }
)
}
}
.padding(.horizontal, 16)
}
.padding(.vertical, 8)
}
// Content
ListAsyncContentView( 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,82 +104,77 @@ struct ContractorsListView: View {
) )
} }
} }
.navigationTitle(L10n.Contractors.title) .navigationBarTitleDisplayMode(.inline)
.navigationBarTitleDisplayMode(.inline) .toolbar {
.toolbar { ToolbarItem(placement: .navigationBarTrailing) {
ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 12) {
HStack(spacing: AppSpacing.sm) { // Favorites Filter
// Favorites Filter (client-side, no API call needed) 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)
Menu {
Button(action: {
selectedSpecialty = nil
}) {
Label(L10n.Contractors.allSpecialties, systemImage: selectedSpecialty == nil ? "checkmark" : "")
}
Divider()
ForEach(specialties, id: \.self) { specialty in
Button(action: {
selectedSpecialty = specialty
}) {
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
}
// Add Button (disabled when showing upgrade screen)
Button(action: {
let currentCount = viewModel.contractors.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
// Track paywall shown
PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount])
showingUpgradePrompt = true
} else {
showingAddSheet = true
}
}) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(Color.appPrimary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
} }
// Specialty Filter
Menu {
Button(action: {
selectedSpecialty = nil
}) {
Label(L10n.Contractors.allSpecialties, systemImage: selectedSpecialty == nil ? "checkmark" : "")
}
Divider()
ForEach(specialties, id: \.self) { specialty in
Button(action: {
selectedSpecialty = specialty
}) {
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
.font(.system(size: 16, weight: .medium))
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
}
// Add Button
Button(action: {
let currentCount = viewModel.contractors.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount])
showingUpgradePrompt = true
} else {
showingAddSheet = true
}
}) {
OrganicToolbarButton(systemName: "plus", isPrimary: true)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
} }
} }
.sheet(isPresented: $showingAddSheet) { }
ContractorFormSheet( .sheet(isPresented: $showingAddSheet) {
contractor: nil, ContractorFormSheet(
onSave: { contractor: nil,
loadContractors() onSave: {
} loadContractors()
) }
.presentationDetents([.large]) )
} .presentationDetents([.large])
.sheet(isPresented: $showingUpgradePrompt) { }
UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt) .sheet(isPresented: $showingUpgradePrompt) {
} UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt)
.onAppear { }
PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown) .onAppear {
loadContractors() PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown)
} 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) {
Image(systemName: "magnifyingglass") ZStack {
.foregroundColor(Color.appTextSecondary) 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) 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.appTextSecondary) .foregroundColor(Color.appTextOnPrimary)
if !hasFilters {
Text(L10n.Contractors.emptyNoFilters)
.font(.callout)
.foregroundColor(Color.appTextSecondary.opacity(0.7))
} }
VStack(alignment: .leading, spacing: 4) {
Text(contractor.name)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
.lineLimit(1)
if let company = contractor.company, !company.isEmpty {
Text(company)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.lineLimit(1)
}
if !contractor.specialties.isEmpty {
HStack(spacing: 4) {
ForEach(contractor.specialties.prefix(2), id: \.id) { specialty in
Text(specialty.name)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color.appPrimary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.appPrimary.opacity(0.1))
.clipShape(Capsule())
}
if contractor.specialties.count > 2 {
Text("+\(contractor.specialties.count - 2)")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
}
}
}
}
Spacer()
// Favorite Button
Button(action: onToggleFavorite) {
Image(systemName: contractor.isFavorite ? "star.fill" : "star")
.font(.system(size: 18, weight: .medium))
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary)
}
.buttonStyle(.plain)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.appTextSecondary.opacity(0.5))
} }
.padding(AppSpacing.xl) .padding(16)
.background(
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: 1)
.fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.04 : 0.02))
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.8)
.offset(x: geo.size.width * 0.6, y: 0)
.blur(radius: 10)
}
GrainTexture(opacity: 0.01)
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.naturalShadow(.medium)
} }
} }
struct ContractorsListView_Previews: PreviewProvider { // MARK: - Organic Toolbar Button
static var previews: some View {
private struct OrganicToolbarButton: View {
let systemName: String
var isPrimary: Bool = false
var body: some View {
ZStack {
if isPrimary {
Circle()
.fill(Color.appPrimary)
.frame(width: 32, height: 32)
Image(systemName: systemName)
.font(.system(size: 14, weight: .bold))
.foregroundColor(Color.appTextOnPrimary)
} else {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: systemName)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
}
}
}
// MARK: - Organic Empty Contractors View
private struct OrganicEmptyContractorsView: View {
let hasFilters: Bool
@State private var isAnimating = false
var body: some View {
VStack(spacing: OrganicSpacing.comfortable) {
Spacer()
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true),
value: isAnimating
)
Image(systemName: "person.badge.plus")
.font(.system(size: 44, weight: .medium))
.foregroundColor(Color.appPrimary)
}
VStack(spacing: 8) {
Text(hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle)
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
if !hasFilters {
Text(L10n.Contractors.emptyNoFilters)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
}
Spacer()
}
.padding(24)
.onAppear {
isAnimating = true
}
}
}
#Preview {
NavigationView {
ContractorsListView() 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) {
Image(systemName: icon) ZStack {
.font(.system(size: 64)) Circle()
.foregroundColor(Color.appTextSecondary) .fill(Color.appPrimary.opacity(0.08))
.frame(width: 100, height: 100)
Image(systemName: icon)
.font(.system(size: 44, weight: .medium))
.foregroundColor(Color.appPrimary.opacity(0.6))
}
Text(title) 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()
// Header
VStack(spacing: AppSpacing.sm) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 80, height: 80)
Image(systemName: "person.badge.plus") // Decorative blobs
.font(.system(size: 36)) GeometryReader { geo in
.foregroundStyle(Color.appPrimary.gradient) OrganicBlobShape(variation: 1)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.06),
Color.appPrimary.opacity(0.01),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.3
)
)
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.25)
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.05)
.blur(radius: 20)
}
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
// Header
VStack(spacing: 16) {
ZStack {
// Pulsing glow
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Image(systemName: "person.badge.plus")
.font(.system(size: 36, weight: .medium))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
} }
Text("Save your home to your account") 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) {
Image(systemName: "envelope.fill") ZStack {
.font(.title3) Circle()
.fill(Color.appPrimary.opacity(0.15))
.frame(width: 36, height: 36)
Image(systemName: "envelope.fill")
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appPrimary)
}
Text("Create Account with Email") 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) {
icon: "person.fill", OrganicOnboardingTextField(
placeholder: "Username", icon: "person.fill",
text: $viewModel.username, placeholder: "Username",
field: .username, text: $viewModel.username,
keyboardType: .default, isFocused: focusedField == .username
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, isFocused: focusedField == .email
field: .email, )
keyboardType: .emailAddress, .focused($focusedField, equals: .email)
contentType: .emailAddress .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, isFocused: focusedField == .password
field: .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, isFocused: focusedField == .confirmPassword
field: .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)
@@ -197,25 +275,24 @@ 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(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appPrimary)
} }
.font(.body) .padding(.top, 8)
.fontWeight(.semibold)
.foregroundColor(Color.appPrimary)
} }
.padding(.top, AppSpacing.md) .padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
} }
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
} }
.background(Color.appBackgroundPrimary)
.sheet(isPresented: $showingLoginSheet) { .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 // MARK: - Organic Onboarding TextField
private func formField( private struct OrganicOnboardingTextField: View {
icon: String, let icon: String
placeholder: String, let placeholder: String
text: Binding<String>, @Binding var text: String
field: Field, var isFocused: Bool = false
keyboardType: UIKeyboardType,
contentType: UITextContentType
) -> some View {
HStack(spacing: AppSpacing.sm) {
Image(systemName: icon)
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
TextField(placeholder, text: text) var body: some View {
.textInputAutocapitalization(.never) HStack(spacing: 14) {
.autocorrectionDisabled() ZStack {
.keyboardType(keyboardType) Circle()
.textContentType(contentType) .fill(Color.appPrimary.opacity(0.1))
.focused($focusedField, equals: field) .frame(width: 36, height: 36)
Image(systemName: icon)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appPrimary)
}
TextField(placeholder, text: $text)
.font(.system(size: 16, weight: .medium))
} }
.padding(AppSpacing.md) .padding(14)
.background(Color.appBackgroundSecondary) .background(Color.appBackgroundPrimary.opacity(0.5))
.cornerRadius(AppRadius.md) .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)
) )
} }
}
private func secureFormField( // MARK: - Organic Onboarding Secure Field
icon: String,
placeholder: String,
text: Binding<String>,
field: Field
) -> some View {
HStack(spacing: AppSpacing.sm) {
Image(systemName: icon)
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
SecureField(placeholder, text: text) private struct OrganicOnboardingSecureField: View {
.textContentType(.password) let icon: String
.focused($focusedField, equals: field) let placeholder: String
@Binding var text: String
var isFocused: Bool = false
@State private var showPassword = false
var body: some View {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 36, height: 36)
Image(systemName: icon)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appPrimary)
}
if showPassword {
TextField(placeholder, text: $text)
.font(.system(size: 16, weight: .medium))
.textContentType(.password)
} else {
SecureField(placeholder, text: $text)
.font(.system(size: 16, weight: .medium))
.textContentType(.password)
}
Button(action: { showPassword.toggle() }) {
Image(systemName: showPassword ? "eye.slash.fill" : "eye.fill")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
} }
.padding(AppSpacing.md) .padding(14)
.background(Color.appBackgroundSecondary) .background(Color.appBackgroundPrimary.opacity(0.5))
.cornerRadius(AppRadius.md) .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)
) )
} }
}
private func errorMessage(_ message: String) -> some View { // MARK: - Organic Error Message
HStack(spacing: AppSpacing.sm) {
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,32 +458,41 @@ struct OnboardingCreateAccountView: View {
var onBack: () -> Void var onBack: () -> Void
var body: some View { var body: some View {
VStack(spacing: 0) { ZStack {
// Navigation bar WarmGradientBackground()
HStack {
Button(action: onBack) { VStack(spacing: 0) {
Image(systemName: "chevron.left") // Navigation bar
.font(.title2) HStack {
.foregroundColor(Color.appPrimary) Button(action: onBack) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 36, height: 36)
Image(systemName: "chevron.left")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
}
Spacer()
OnboardingProgressIndicator(currentStep: 3, totalSteps: 5)
Spacer()
// Invisible spacer for alignment
Circle()
.fill(Color.clear)
.frame(width: 36, height: 36)
} }
.padding(.horizontal, 20)
.padding(.vertical, 12)
Spacer() OnboardingCreateAccountContent(onAccountCreated: onAccountCreated)
OnboardingProgressIndicator(currentStep: 3, totalSteps: 5)
Spacer()
// Invisible spacer for alignment
Image(systemName: "chevron.left")
.font(.title2)
.opacity(0)
} }
.padding(.horizontal, AppSpacing.lg)
.padding(.vertical, AppSpacing.md)
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,196 +101,243 @@ struct OnboardingFirstTaskContent: View {
} }
var body: some View { var body: some View {
VStack(spacing: 0) { ZStack {
ScrollView { WarmGradientBackground()
VStack(spacing: AppSpacing.xl) {
// Header with celebration
VStack(spacing: AppSpacing.md) {
ZStack {
// Celebration circles
Circle()
.fill(
RadialGradient(
colors: [Color.appPrimary.opacity(0.2), Color.clear],
center: .center,
startRadius: 30,
endRadius: 80
)
)
.frame(width: 140, height: 140)
.offset(x: -15, y: -15)
Circle() // Decorative blobs
.fill( GeometryReader { geo in
RadialGradient( OrganicBlobShape(variation: 1)
colors: [Color.appAccent.opacity(0.2), Color.clear], .fill(
center: .center, RadialGradient(
startRadius: 30, colors: [
endRadius: 80 Color.appPrimary.opacity(0.06),
) Color.appPrimary.opacity(0.01),
) Color.clear
.frame(width: 140, height: 140) ],
.offset(x: 15, y: 15) center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.3
)
)
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.25)
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.1)
.blur(radius: 20)
// Party icon OrganicBlobShape(variation: 2)
.fill(
RadialGradient(
colors: [
Color.appAccent.opacity(0.05),
Color.appAccent.opacity(0.01),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.25
)
)
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.2)
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.75)
.blur(radius: 15)
}
VStack(spacing: 0) {
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
// Header with celebration
VStack(spacing: 16) {
ZStack { ZStack {
// Celebration circles
Circle() Circle()
.fill( .fill(
LinearGradient( RadialGradient(
colors: [Color.appPrimary, Color.appSecondary], colors: [Color.appPrimary.opacity(0.15), Color.clear],
startPoint: .topLeading, center: .center,
endPoint: .bottomTrailing startRadius: 30,
endRadius: 80
) )
) )
.frame(width: 80, height: 80) .frame(width: 140, height: 140)
.offset(x: -15, y: -15)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
Image(systemName: "party.popper.fill") Circle()
.font(.system(size: 36)) .fill(
.foregroundColor(.white) RadialGradient(
colors: [Color.appAccent.opacity(0.15), Color.clear],
center: .center,
startRadius: 30,
endRadius: 80
)
)
.frame(width: 140, height: 140)
.offset(x: 15, y: 15)
.scaleEffect(isAnimating ? 0.95 : 1.05)
.animation(
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5),
value: isAnimating
)
// Party icon
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appSecondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Image(systemName: "party.popper.fill")
.font(.system(size: 36))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
} }
.shadow(color: Color.appPrimary.opacity(0.4), radius: 15, y: 8)
Text("You're all set up!")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
} }
.padding(.top, OrganicSpacing.comfortable)
Text("You're all set up!") // Selection counter chip
.font(.title) HStack(spacing: 8) {
.fontWeight(.bold) Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill")
.foregroundColor(Color.appTextPrimary) .foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!") Text("\(selectedCount)/\(maxTasksAllowed) tasks selected")
.font(.subheadline) .font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextSecondary) .foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary)
.multilineTextAlignment(.center) }
.lineSpacing(4) .padding(.horizontal, 18)
} .padding(.vertical, 10)
.padding(.top, AppSpacing.lg) .background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1))
.clipShape(Capsule())
.animation(.spring(response: 0.3), value: selectedCount)
// Selection counter chip // Task categories
HStack(spacing: AppSpacing.sm) { VStack(spacing: 12) {
Image(systemName: isAtMaxSelection ? "checkmark.seal.fill" : "checkmark.circle.fill") ForEach(taskCategories) { category in
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary) OrganicTaskCategorySection(
category: category,
Text("\(selectedCount)/\(maxTasksAllowed) tasks selected") selectedTasks: $selectedTasks,
.font(.subheadline) isExpanded: expandedCategory == category.name,
.fontWeight(.medium) isAtMaxSelection: isAtMaxSelection,
.foregroundColor(isAtMaxSelection ? Color.appAccent : Color.appPrimary) onToggleExpand: {
} withAnimation(.spring(response: 0.3)) {
.padding(.horizontal, AppSpacing.lg) if expandedCategory == category.name {
.padding(.vertical, AppSpacing.sm) expandedCategory = nil
.background((isAtMaxSelection ? Color.appAccent : Color.appPrimary).opacity(0.1)) } else {
.cornerRadius(AppRadius.xl) expandedCategory = category.name
.animation(.spring(response: 0.3), value: selectedCount) }
// Task categories
VStack(spacing: AppSpacing.md) {
ForEach(taskCategories) { category in
TaskCategorySection(
category: category,
selectedTasks: $selectedTasks,
isExpanded: expandedCategory == category.name,
isAtMaxSelection: isAtMaxSelection,
onToggleExpand: {
withAnimation(.spring(response: 0.3)) {
if expandedCategory == category.name {
expandedCategory = nil
} else {
expandedCategory = category.name
} }
} }
} )
}
}
.padding(.horizontal, OrganicSpacing.comfortable)
// Quick add all popular
Button(action: selectPopularTasks) {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.system(size: 16, weight: .semibold))
Text("Add Most Popular")
.font(.system(size: 16, weight: .semibold))
}
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appAccent],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(
LinearGradient(
colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
startPoint: .leading,
endPoint: .trailing
),
lineWidth: 1.5
)
) )
} }
.padding(.horizontal, OrganicSpacing.comfortable)
} }
.padding(.horizontal, AppSpacing.lg) .padding(.bottom, 140) // Space for button
}
// Quick add all popular // Bottom action area
Button(action: selectPopularTasks) { VStack(spacing: 14) {
HStack(spacing: AppSpacing.sm) { Button(action: addSelectedTasks) {
Image(systemName: "sparkles") HStack(spacing: 10) {
.font(.headline) if isCreatingTasks {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now")
.font(.system(size: 17, weight: .bold))
Text("Add Most Popular") Image(systemName: "arrow.right")
.font(.headline) .font(.system(size: 16, weight: .bold))
.fontWeight(.medium) }
} }
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appAccent],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 56) .frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background( .background(
LinearGradient( selectedCount > 0
colors: [Color.appPrimary.opacity(0.1), Color.appAccent.opacity(0.1)], ? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
startPoint: .leading, : AnyShapeStyle(Color.appTextSecondary.opacity(0.5))
endPoint: .trailing
)
)
.cornerRadius(AppRadius.lg)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.lg)
.stroke(
LinearGradient(
colors: [Color.appPrimary.opacity(0.3), Color.appAccent.opacity(0.3)],
startPoint: .leading,
endPoint: .trailing
),
lineWidth: 1.5
)
) )
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(selectedCount > 0 ? .medium : .subtle)
} }
.padding(.horizontal, AppSpacing.lg) .disabled(isCreatingTasks)
.animation(.easeInOut(duration: 0.2), value: selectedCount)
} }
.padding(.bottom, 140) // Space for button .padding(.horizontal, OrganicSpacing.comfortable)
} .padding(.bottom, OrganicSpacing.airy)
.background(
// Bottom action area LinearGradient(
VStack(spacing: AppSpacing.md) { colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
Button(action: addSelectedTasks) { startPoint: .top,
HStack(spacing: AppSpacing.sm) { endPoint: .center
if isCreatingTasks {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text(selectedCount > 0 ? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue" : "Skip for Now")
.font(.headline)
.fontWeight(.bold)
Image(systemName: "arrow.right")
.font(.headline)
}
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
selectedCount > 0
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
: AnyShapeStyle(Color.appTextSecondary.opacity(0.5))
) )
.cornerRadius(AppRadius.lg) .frame(height: 60)
.shadow(color: selectedCount > 0 ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8) .offset(y: -60)
} , alignment: .top
.disabled(isCreatingTasks)
.animation(.easeInOut(duration: 0.2), value: selectedCount)
}
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
.background(
LinearGradient(
colors: [Color.appBackgroundPrimary.opacity(0), Color.appBackgroundPrimary],
startPoint: .top,
endPoint: .center
) )
.frame(height: 60) }
.offset(y: -60)
, alignment: .top
)
} }
.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,27 +638,29 @@ struct OnboardingFirstTaskView: View {
var onSkip: () -> Void var onSkip: () -> Void
var body: some View { var body: some View {
VStack(spacing: 0) { ZStack {
// Navigation bar WarmGradientBackground()
HStack {
Spacer()
Button(action: onSkip) { VStack(spacing: 0) {
Text("Skip") // Navigation bar
.font(.subheadline) HStack {
.fontWeight(.medium) Spacer()
.foregroundColor(Color.appTextSecondary)
Button(action: onSkip) {
Text("Skip")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
} }
} .padding(.horizontal, 20)
.padding(.horizontal, AppSpacing.lg) .padding(.vertical, 12)
.padding(.vertical, AppSpacing.md)
OnboardingFirstTaskContent( OnboardingFirstTaskContent(
residenceName: residenceName, residenceName: residenceName,
onTaskAdded: onTaskAdded onTaskAdded: onTaskAdded
) )
}
} }
.background(Color.appBackgroundPrimary)
} }
} }

View File

@@ -9,123 +9,215 @@ 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 {
VStack(spacing: 0) { ZStack {
Spacer() WarmGradientBackground()
// Content // Decorative blobs
VStack(spacing: AppSpacing.xl) { GeometryReader { geo in
// Icon OrganicBlobShape(variation: 1)
ZStack { .fill(
Circle() RadialGradient(
.fill(Color.appPrimary.opacity(0.1)) colors: [
.frame(width: 100, height: 100) Color.appPrimary.opacity(0.07),
Color.appPrimary.opacity(0.02),
Image(systemName: "person.2.badge.key.fill") Color.clear
.font(.system(size: 44)) ],
.foregroundStyle(Color.appPrimary.gradient) center: .center,
} startRadius: 0,
endRadius: geo.size.width * 0.35
// Title )
VStack(spacing: AppSpacing.sm) {
Text("Join a Residence")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
Text("Enter the 6-character code shared with you to join an existing home.")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
// Code input
VStack(alignment: .leading, spacing: AppSpacing.xs) {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "key.fill")
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
TextField("Enter share code", text: $shareCode)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.focused($isCodeFieldFocused)
.onChange(of: shareCode) { _, newValue in
// Limit to 6 characters
if newValue.count > 6 {
shareCode = String(newValue.prefix(6))
}
// Clear error when typing
errorMessage = nil
}
}
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.md)
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
) )
} .frame(width: geo.size.width * 0.6, height: geo.size.height * 0.35)
.padding(.horizontal, AppSpacing.xl) .offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.1)
.blur(radius: 25)
// Error message OrganicBlobShape(variation: 2)
if let error = errorMessage { .fill(
HStack(spacing: AppSpacing.sm) { RadialGradient(
Image(systemName: "exclamationmark.circle.fill") colors: [
.foregroundColor(Color.appError) Color.appAccent.opacity(0.05),
Text(error) Color.appAccent.opacity(0.01),
.font(.callout) Color.clear
.foregroundColor(Color.appError) ],
Spacer() center: .center,
} startRadius: 0,
.padding(AppSpacing.md) endRadius: geo.size.width * 0.25
.background(Color.appError.opacity(0.1)) )
.cornerRadius(AppRadius.md) )
.padding(.horizontal, AppSpacing.xl) .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)
// Loading indicator
if isLoading {
HStack {
ProgressView()
Text("Joining residence...")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
}
} }
Spacer() VStack(spacing: 0) {
Spacer()
// Join button // Content
Button(action: joinResidence) { VStack(spacing: OrganicSpacing.comfortable) {
Text("Join Residence") // Icon with pulsing glow
.font(.headline) ZStack {
.fontWeight(.semibold) Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 70
)
)
.frame(width: 140, height: 140)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 90, height: 90)
Image(systemName: "person.2.badge.key.fill")
.font(.system(size: 40, weight: .medium))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
// Title
VStack(spacing: 10) {
Text("Join a Residence")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text("Enter the 6-character code shared with you to join an existing home.")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
.padding(.horizontal, 20)
}
// Code input card
VStack(spacing: 16) {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "key.fill")
.font(.system(size: 17, weight: .medium))
.foregroundColor(Color.appPrimary)
}
TextField("Enter share code", text: $shareCode)
.font(.system(size: 20, weight: .semibold, design: .monospaced))
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.focused($isCodeFieldFocused)
.onChange(of: shareCode) { _, newValue in
// Limit to 6 characters
if newValue.count > 6 {
shareCode = String(newValue.prefix(6))
}
// Clear error when typing
errorMessage = nil
}
}
.padding(18)
.background(
ZStack {
Color.appBackgroundSecondary
GrainTexture(opacity: 0.01)
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 2)
)
.naturalShadow(.medium)
}
.padding(.horizontal, OrganicSpacing.comfortable)
// Error message
if let error = errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(14)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.horizontal, OrganicSpacing.comfortable)
}
// Loading indicator
if isLoading {
HStack(spacing: 10) {
ProgressView()
.tint(Color.appPrimary)
Text("Joining residence...")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
}
Spacer()
// Join button
Button(action: joinResidence) {
HStack(spacing: 10) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(isLoading ? "Joining..." : "Join Residence")
.font(.system(size: 17, weight: .semibold))
}
.frame(maxWidth: .infinity) .frame(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)
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
} }
.disabled(!isCodeValid || isLoading)
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
} }
.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,24 +253,26 @@ struct OnboardingJoinResidenceView: View {
var onSkip: () -> Void var onSkip: () -> Void
var body: some View { var body: some View {
VStack(spacing: 0) { ZStack {
// Navigation bar WarmGradientBackground()
HStack {
Spacer()
Button(action: onSkip) { VStack(spacing: 0) {
Text("Skip") // Navigation bar
.font(.subheadline) HStack {
.fontWeight(.medium) Spacer()
.foregroundColor(Color.appTextSecondary)
Button(action: onSkip) {
Text("Skip")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
} }
} .padding(.horizontal, 20)
.padding(.horizontal, AppSpacing.lg) .padding(.vertical, 12)
.padding(.vertical, AppSpacing.md)
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,177 +23,240 @@ struct OnboardingNameResidenceContent: View {
] ]
var body: some View { var body: some View {
VStack(spacing: 0) { ZStack {
Spacer() WarmGradientBackground()
// Content // Decorative blobs
VStack(spacing: AppSpacing.xl) { GeometryReader { geo in
// Animated house icon OrganicBlobShape(variation: 2)
ZStack { .fill(
// Colorful background circles RadialGradient(
Circle() colors: [
.fill( Color.appAccent.opacity(0.08),
RadialGradient( Color.appAccent.opacity(0.02),
colors: [Color.appAccent.opacity(0.2), Color.clear], Color.clear
center: .center, ],
startRadius: 30, center: .center,
endRadius: 80 startRadius: 0,
) endRadius: geo.size.width * 0.35
) )
.frame(width: 160, height: 160) )
.offset(x: -20, y: -20) .frame(width: geo.size.width * 0.6, height: geo.size.height * 0.35)
.offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.05)
.blur(radius: 25)
Circle() OrganicBlobShape(variation: 0)
.fill( .fill(
RadialGradient( RadialGradient(
colors: [Color.appPrimary.opacity(0.2), Color.clear], colors: [
center: .center, Color.appPrimary.opacity(0.06),
startRadius: 30, Color.appPrimary.opacity(0.01),
endRadius: 80 Color.clear
) ],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.3
) )
.frame(width: 160, height: 160) )
.offset(x: 20, y: 20) .frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3)
.offset(x: geo.size.width * 0.55, y: geo.size.height * 0.6)
.blur(radius: 20)
}
// Main icon VStack(spacing: 0) {
Image("icon") Spacer()
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.shadow(color: Color.appPrimary.opacity(0.3), radius: 15, y: 8)
}
// Title with playful wording // Content
VStack(spacing: AppSpacing.md) { VStack(spacing: OrganicSpacing.comfortable) {
Text("Let's give your place a name!") // Animated house icon
.font(.title) ZStack {
.fontWeight(.bold) // Pulsing glow circles
.foregroundColor(Color.appTextPrimary) Circle()
.multilineTextAlignment(.center) .fill(
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle) RadialGradient(
colors: [Color.appAccent.opacity(0.15), Color.clear],
Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.") center: .center,
.font(.subheadline) startRadius: 30,
.foregroundColor(Color.appTextSecondary) endRadius: 80
.multilineTextAlignment(.center)
.lineSpacing(4)
}
// Text field with gradient border when focused
VStack(alignment: .leading, spacing: AppSpacing.sm) {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "house.fill")
.font(.title3)
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appAccent],
startPoint: .topLeading,
endPoint: .bottomTrailing
) )
) )
.frame(width: 24) .frame(width: 160, height: 160)
.offset(x: -20, y: -20)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
TextField("The Smith Residence", text: $residenceName) Circle()
.font(.body) .fill(
.fontWeight(.medium) RadialGradient(
.textInputAutocapitalization(.words) colors: [Color.appPrimary.opacity(0.15), Color.clear],
.focused($isTextFieldFocused) center: .center,
.submitLabel(.continue) startRadius: 30,
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField) endRadius: 80
.onSubmit { )
if isValid { )
onContinue() .frame(width: 160, height: 160)
.offset(x: 20, y: 20)
.scaleEffect(isAnimating ? 0.95 : 1.05)
.animation(
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5),
value: isAnimating
)
// Main icon
Image("icon")
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.naturalShadow(.pronounced)
}
// Title with playful wording
VStack(spacing: 12) {
Text("Let's give your place a name!")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle)
Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
// Text field with organic styling
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary.opacity(0.15), Color.appAccent.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 40, height: 40)
Image(systemName: "house.fill")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(
LinearGradient(
colors: [Color.appPrimary, Color.appAccent],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
TextField("The Smith Residence", text: $residenceName)
.font(.system(size: 17, weight: .medium))
.textInputAutocapitalization(.words)
.focused($isTextFieldFocused)
.submitLabel(.continue)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField)
.onSubmit {
if isValid {
onContinue()
}
}
if !residenceName.isEmpty {
Button(action: { residenceName = "" }) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(Color.appTextSecondary.opacity(0.5))
} }
} }
if !residenceName.isEmpty {
Button(action: { residenceName = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(Color.appTextSecondary.opacity(0.5))
}
} }
} .padding(18)
.padding(AppSpacing.lg) .background(
.background(Color.appBackgroundSecondary) ZStack {
.cornerRadius(AppRadius.lg) Color.appBackgroundSecondary
.overlay( GrainTexture(opacity: 0.01)
RoundedRectangle(cornerRadius: AppRadius.lg) }
.stroke( )
isTextFieldFocused .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
? LinearGradient(colors: [Color.appPrimary, Color.appAccent], startPoint: .leading, endPoint: .trailing) .overlay(
: LinearGradient(colors: [Color.appTextSecondary.opacity(0.3), Color.appTextSecondary.opacity(0.3)], startPoint: .leading, endPoint: .trailing), RoundedRectangle(cornerRadius: 20, style: .continuous)
lineWidth: 2 .stroke(
) isTextFieldFocused
) ? LinearGradient(colors: [Color.appPrimary, Color.appAccent], startPoint: .leading, endPoint: .trailing)
.shadow(color: isTextFieldFocused ? Color.appPrimary.opacity(0.15) : .clear, radius: 12, y: 4) : LinearGradient(colors: [Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0.2)], startPoint: .leading, endPoint: .trailing),
.animation(.easeInOut(duration: 0.2), value: isTextFieldFocused) lineWidth: 2
)
)
.naturalShadow(isTextFieldFocused ? .medium : .subtle)
.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, 4)
.padding(.top, AppSpacing.xs)
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)) {
residenceName = suggestion residenceName = suggestion
}
}) {
Text(suggestion)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color.appPrimary)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.appPrimary.opacity(0.1))
.clipShape(Capsule())
} }
}) {
Text(suggestion)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(Color.appPrimary)
.padding(.horizontal, AppSpacing.md)
.padding(.vertical, AppSpacing.sm)
.background(Color.appPrimary.opacity(0.1))
.cornerRadius(AppRadius.md)
} }
} }
} }
} }
} }
} }
.padding(.horizontal, OrganicSpacing.comfortable)
} }
.padding(.horizontal, AppSpacing.xl)
}
Spacer() Spacer()
// Continue button // 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(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
isValid
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
: AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(isValid ? .medium : .subtle)
} }
.frame(maxWidth: .infinity) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton)
.frame(height: 56) .disabled(!isValid)
.foregroundColor(Color.appTextOnPrimary) .padding(.horizontal, OrganicSpacing.comfortable)
.background( .padding(.bottom, OrganicSpacing.airy)
isValid .animation(.easeInOut(duration: 0.2), value: isValid)
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
: AnyShapeStyle(Color.appTextSecondary.opacity(0.5))
)
.cornerRadius(AppRadius.lg)
.shadow(color: isValid ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton)
.disabled(!isValid)
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
.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,35 +272,44 @@ struct OnboardingNameResidenceView: View {
var onBack: () -> Void var onBack: () -> Void
var body: some View { var body: some View {
VStack(spacing: 0) { ZStack {
// Navigation bar WarmGradientBackground()
HStack {
Button(action: onBack) { VStack(spacing: 0) {
Image(systemName: "chevron.left") // Navigation bar
.font(.title2) HStack {
.foregroundColor(Color.appPrimary) Button(action: onBack) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 36, height: 36)
Image(systemName: "chevron.left")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
}
Spacer()
OnboardingProgressIndicator(currentStep: 2, totalSteps: 5)
Spacer()
// Invisible spacer for alignment
Circle()
.fill(Color.clear)
.frame(width: 36, height: 36)
} }
.padding(.horizontal, 20)
.padding(.vertical, 12)
Spacer() OnboardingNameResidenceContent(
residenceName: $residenceName,
OnboardingProgressIndicator(currentStep: 2, totalSteps: 5) onContinue: onContinue
)
Spacer()
// Invisible spacer for alignment
Image(systemName: "chevron.left")
.font(.title2)
.opacity(0)
} }
.padding(.horizontal, AppSpacing.lg)
.padding(.vertical, AppSpacing.md)
OnboardingNameResidenceContent(
residenceName: $residenceName,
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,181 +50,233 @@ struct OnboardingSubscriptionContent: View {
] ]
var body: some View { var body: some View {
ScrollView { ZStack {
VStack(spacing: AppSpacing.xl) { WarmGradientBackground()
// Header with animated crown
VStack(spacing: AppSpacing.md) {
ZStack {
// Glow effect
Circle()
.fill(
RadialGradient(
colors: [Color.appAccent.opacity(0.3), Color.clear],
center: .center,
startRadius: 30,
endRadius: 100
)
)
.frame(width: 180, height: 180)
.scaleEffect(animateBadge ? 1.1 : 1.0)
.animation(.easeInOut(duration: 2).repeatForever(autoreverses: true), value: animateBadge)
// Crown icon // Decorative blobs
ZStack { GeometryReader { geo in
Circle() OrganicBlobShape(variation: 0)
.fill( .fill(
LinearGradient( RadialGradient(
colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], colors: [
startPoint: .topLeading, Color.appAccent.opacity(0.08),
endPoint: .bottomTrailing Color.appAccent.opacity(0.02),
) Color.clear
) ],
.frame(width: 100, height: 100) center: .center,
startRadius: 0,
Image(systemName: "crown.fill") endRadius: geo.size.width * 0.35
.font(.system(size: 44))
.foregroundColor(.white)
}
.shadow(color: Color.appAccent.opacity(0.5), radius: 20, y: 10)
}
// Pro badge
HStack(spacing: AppSpacing.xs) {
Image(systemName: "sparkles")
.foregroundColor(Color.appAccent)
Text("CASERA PRO")
.font(.headline)
.fontWeight(.black)
.foregroundColor(Color.appAccent)
Image(systemName: "sparkles")
.foregroundColor(Color.appAccent)
}
.padding(.horizontal, AppSpacing.lg)
.padding(.vertical, AppSpacing.sm)
.background(
LinearGradient(
colors: [Color.appAccent.opacity(0.15), Color(hex: "#FF9500")?.opacity(0.15) ?? Color.orange.opacity(0.15)],
startPoint: .leading,
endPoint: .trailing
) )
) )
.clipShape(Capsule()) .frame(width: geo.size.width * 0.6, height: geo.size.height * 0.3)
.offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.05)
.blur(radius: 25)
Text("Take your home management\nto the next level") OrganicBlobShape(variation: 2)
.font(.title2) .fill(
.fontWeight(.bold) RadialGradient(
.foregroundColor(Color.appTextPrimary) colors: [
.multilineTextAlignment(.center) Color.appPrimary.opacity(0.06),
.lineSpacing(4) Color.appPrimary.opacity(0.01),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.25
)
)
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.2)
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.7)
.blur(radius: 20)
}
// Social proof ScrollView(showsIndicators: false) {
HStack(spacing: AppSpacing.xs) { VStack(spacing: OrganicSpacing.comfortable) {
ForEach(0..<5, id: \.self) { _ in // Header with animated crown
Image(systemName: "star.fill") VStack(spacing: 16) {
.font(.caption) ZStack {
// Pulsing glow effect
Circle()
.fill(
RadialGradient(
colors: [Color.appAccent.opacity(0.25), Color.appAccent.opacity(0.05), Color.clear],
center: .center,
startRadius: 30,
endRadius: 100
)
)
.frame(width: 180, height: 180)
.scaleEffect(animateBadge ? 1.1 : 1.0)
.animation(.easeInOut(duration: 2).repeatForever(autoreverses: true), value: animateBadge)
// Crown icon
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 100, height: 100)
Image(systemName: "crown.fill")
.font(.system(size: 44))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
// Pro badge
HStack(spacing: 6) {
Image(systemName: "sparkles")
.foregroundColor(Color.appAccent)
Text("CASERA PRO")
.font(.system(size: 14, weight: .black))
.foregroundColor(Color.appAccent)
Image(systemName: "sparkles")
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
} }
Text("4.9") .padding(.horizontal, 18)
.font(.subheadline) .padding(.vertical, 10)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
Text("• 10K+ homeowners")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
}
.padding(.top, AppSpacing.lg)
// Benefits list with gradient icons
VStack(spacing: AppSpacing.sm) {
ForEach(benefits) { benefit in
SubscriptionBenefitRow(benefit: benefit)
}
}
.padding(.horizontal, AppSpacing.lg)
// Pricing plans
VStack(spacing: AppSpacing.md) {
Text("Choose your plan")
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
// Yearly plan (best value)
PricingPlanCard(
plan: .yearly,
isSelected: selectedPlan == .yearly,
onSelect: { selectedPlan = .yearly }
)
// Monthly plan
PricingPlanCard(
plan: .monthly,
isSelected: selectedPlan == .monthly,
onSelect: { selectedPlan = .monthly }
)
}
.padding(.horizontal, AppSpacing.lg)
// CTA buttons
VStack(spacing: AppSpacing.md) {
Button(action: startFreeTrial) {
HStack(spacing: AppSpacing.sm) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text("Start 7-Day Free Trial")
.font(.headline)
.fontWeight(.bold)
Image(systemName: "arrow.right")
.font(.headline)
}
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background( .background(
LinearGradient( LinearGradient(
colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange], colors: [Color.appAccent.opacity(0.15), Color(hex: "#FF9500")?.opacity(0.15) ?? Color.orange.opacity(0.15)],
startPoint: .leading, startPoint: .leading,
endPoint: .trailing endPoint: .trailing
) )
) )
.cornerRadius(AppRadius.lg) .clipShape(Capsule())
.shadow(color: Color.appAccent.opacity(0.4), radius: 15, y: 8)
}
.disabled(isLoading)
// Continue without Text("Take your home management\nto the next level")
Button(action: { .font(.system(size: 22, weight: .bold, design: .rounded))
onSubscribe() .foregroundColor(Color.appTextPrimary)
}) { .multilineTextAlignment(.center)
Text("Continue with Free") .lineSpacing(4)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appTextSecondary)
}
// Legal text // Social proof
VStack(spacing: AppSpacing.xs) { HStack(spacing: 6) {
Text("7-day free trial, then \(selectedPlan == .yearly ? "$23.99/year" : "$2.99/month")") ForEach(0..<5, id: \.self) { _ in
.font(.caption) Image(systemName: "star.fill")
.foregroundColor(Color.appTextSecondary) .font(.system(size: 12))
.foregroundColor(Color.appAccent)
Text("Cancel anytime in Settings • No commitment") }
.font(.caption) Text("4.9")
.foregroundColor(Color.appTextSecondary.opacity(0.7)) .font(.system(size: 14, weight: .bold))
.foregroundColor(Color.appTextPrimary)
Text("• 10K+ homeowners")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
} }
.multilineTextAlignment(.center) .padding(.top, OrganicSpacing.comfortable)
.padding(.top, AppSpacing.xs)
// Benefits list card
VStack(spacing: 10) {
ForEach(benefits) { benefit in
OrganicSubscriptionBenefitRow(benefit: benefit)
}
}
.padding(OrganicSpacing.cozy)
.background(
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: 1)
.fill(Color.appAccent.opacity(colorScheme == .dark ? 0.06 : 0.04))
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.5)
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.3)
.blur(radius: 20)
}
GrainTexture(opacity: 0.015)
}
)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.naturalShadow(.medium)
.padding(.horizontal, 20)
// Pricing plans
VStack(spacing: 14) {
Text("Choose your plan")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
// Yearly plan (best value)
OrganicPricingPlanCard(
plan: .yearly,
isSelected: selectedPlan == .yearly,
onSelect: { selectedPlan = .yearly }
)
// Monthly plan
OrganicPricingPlanCard(
plan: .monthly,
isSelected: selectedPlan == .monthly,
onSelect: { selectedPlan = .monthly }
)
}
.padding(.horizontal, 20)
// CTA buttons
VStack(spacing: 14) {
Button(action: startFreeTrial) {
HStack(spacing: 10) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text("Start 7-Day Free Trial")
.font(.system(size: 17, weight: .bold))
Image(systemName: "arrow.right")
.font(.system(size: 16, weight: .bold))
}
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
LinearGradient(
colors: [Color.appAccent, Color(hex: "#FF9500") ?? .orange],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(.medium)
}
.disabled(isLoading)
// Continue without
Button(action: {
onSubscribe()
}) {
Text("Continue with Free")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
}
// Legal text
VStack(spacing: 4) {
Text("7-day free trial, then \(selectedPlan == .yearly ? "$23.99/year" : "$2.99/month")")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
Text("Cancel anytime in Settings • No commitment")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary.opacity(0.7))
}
.multilineTextAlignment(.center)
.padding(.top, 4)
}
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
} }
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
} }
} }
.background(Color.appBackgroundPrimary)
.onAppear { .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,56 +56,58 @@ struct OnboardingValuePropsContent: View {
] ]
var body: some View { var body: some View {
VStack(spacing: 0) { ZStack {
// Feature cards in a tab view WarmGradientBackground()
TabView(selection: $currentPage) {
ForEach(Array(features.enumerated()), id: \.offset) { index, feature in
FeatureCard(feature: feature, isActive: currentPage == index)
.tag(index)
.padding(.horizontal, AppSpacing.lg)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(maxHeight: .infinity)
// Custom page indicator VStack(spacing: 0) {
HStack(spacing: AppSpacing.sm) { // Feature cards in a tab view
ForEach(0..<features.count, id: \.self) { index in TabView(selection: $currentPage) {
Capsule() ForEach(Array(features.enumerated()), id: \.offset) { index, feature in
.fill(currentPage == index ? Color.appPrimary : Color.appTextSecondary.opacity(0.3)) OrganicFeatureCard(feature: feature, isActive: currentPage == index)
.frame(width: currentPage == index ? 24 : 8, height: 8) .tag(index)
.animation(.spring(response: 0.3), value: currentPage) .padding(.horizontal, 20)
}
} }
} .tabViewStyle(.page(indexDisplayMode: .never))
.padding(.bottom, AppSpacing.xl) .frame(maxHeight: .infinity)
// Continue button // Custom page indicator
Button(action: onContinue) { HStack(spacing: 10) {
HStack(spacing: AppSpacing.sm) { ForEach(0..<features.count, id: \.self) { index in
Text("I'm Ready!") Capsule()
.font(.headline) .fill(currentPage == index ? Color.appPrimary : Color.appTextSecondary.opacity(0.25))
.fontWeight(.bold) .frame(width: currentPage == index ? 28 : 8, height: 8)
.animation(.spring(response: 0.3), value: currentPage)
Image(systemName: "arrow.right") }
.font(.headline)
} }
.frame(maxWidth: .infinity) .padding(.bottom, OrganicSpacing.comfortable)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary) // Continue button
.background( Button(action: onContinue) {
LinearGradient( HStack(spacing: 10) {
colors: [Color.appPrimary, Color.appSecondary], Text("I'm Ready!")
startPoint: .leading, .font(.system(size: 17, weight: .bold))
endPoint: .trailing
Image(systemName: "arrow.right")
.font(.system(size: 16, weight: .bold))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
LinearGradient(
colors: [Color.appPrimary, Color.appSecondary],
startPoint: .leading,
endPoint: .trailing
)
) )
) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.cornerRadius(AppRadius.lg) .naturalShadow(.medium)
.shadow(color: Color.appPrimary.opacity(0.4), radius: 15, y: 8) }
.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,46 +143,46 @@ 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
Circle() ZStack {
.fill( Circle()
LinearGradient( .fill(
colors: feature.gradient, LinearGradient(
startPoint: .topLeading, colors: feature.gradient,
endPoint: .bottomTrailing startPoint: .topLeading,
endPoint: .bottomTrailing
)
) )
) .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,34 +274,42 @@ struct OnboardingValuePropsView: View {
var onBack: () -> Void var onBack: () -> Void
var body: some View { var body: some View {
VStack(spacing: 0) { ZStack {
// Navigation bar WarmGradientBackground()
HStack {
Button(action: onBack) { VStack(spacing: 0) {
Image(systemName: "chevron.left") // Navigation bar
.font(.title2) HStack {
.foregroundColor(Color.appPrimary) Button(action: onBack) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 36, height: 36)
Image(systemName: "chevron.left")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
}
Spacer()
OnboardingProgressIndicator(currentStep: 1, totalSteps: 5)
Spacer()
Button(action: onSkip) {
Text("Skip")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
} }
.padding(.horizontal, 20)
.padding(.vertical, 12)
Spacer() OnboardingValuePropsContent(onContinue: onContinue)
OnboardingProgressIndicator(currentStep: 1, totalSteps: 5)
Spacer()
Button(action: onSkip) {
Text("Skip")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appTextSecondary)
}
} }
.padding(.horizontal, AppSpacing.lg)
.padding(.vertical, AppSpacing.md)
OnboardingValuePropsContent(onContinue: onContinue)
} }
.background(Color.appBackgroundPrimary)
} }
} }

View File

@@ -8,133 +8,232 @@ 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 {
VStack(spacing: 0) { ZStack {
Spacer() WarmGradientBackground()
// Content // Decorative blobs
VStack(spacing: AppSpacing.xl) { GeometryReader { geo in
// Icon OrganicBlobShape(variation: 0)
ZStack { .fill(
Circle() RadialGradient(
.fill(Color.appPrimary.opacity(0.1)) colors: [
.frame(width: 100, height: 100) Color.appPrimary.opacity(0.06),
Color.appPrimary.opacity(0.01),
Image(systemName: "envelope.badge.fill") Color.clear
.font(.system(size: 44)) ],
.foregroundStyle(Color.appPrimary.gradient) center: .center,
} startRadius: 0,
endRadius: geo.size.width * 0.3
// Title )
VStack(spacing: AppSpacing.sm) {
Text("Verify your email")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle)
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
// Code input
VStack(alignment: .leading, spacing: AppSpacing.xs) {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "key.fill")
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
TextField("Enter 6-digit code", text: $viewModel.code)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.focused($isCodeFieldFocused)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
.keyboardDismissToolbar()
.onChange(of: viewModel.code) { _, newValue in
// Limit to 6 digits
if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6))
}
// Auto-verify when 6 digits entered
if newValue.count == 6 {
viewModel.verifyEmail()
}
}
}
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.md)
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
) )
} .frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3)
.padding(.horizontal, AppSpacing.xl) .offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.1)
.blur(radius: 20)
// Error message OrganicBlobShape(variation: 2)
if let error = viewModel.errorMessage { .fill(
HStack(spacing: AppSpacing.sm) { RadialGradient(
Image(systemName: "exclamationmark.circle.fill") colors: [
.foregroundColor(Color.appError) Color.appAccent.opacity(0.05),
Text(error) Color.appAccent.opacity(0.01),
.font(.callout) Color.clear
.foregroundColor(Color.appError) ],
Spacer() center: .center,
} startRadius: 0,
.padding(AppSpacing.md) endRadius: geo.size.width * 0.25
.background(Color.appError.opacity(0.1)) )
.cornerRadius(AppRadius.md) )
.padding(.horizontal, AppSpacing.xl) .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)
// Loading indicator
if viewModel.isLoading {
HStack {
ProgressView()
Text("Verifying...")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
}
// Resend code hint
Text("Didn't receive a code? Check your spam folder or re-register")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
} }
Spacer() VStack(spacing: 0) {
Spacer()
// Verify button // Content
Button(action: { VStack(spacing: OrganicSpacing.comfortable) {
viewModel.verifyEmail() // Icon with pulsing glow
}) { ZStack {
Text("Verify") Circle()
.font(.headline) .fill(
.fontWeight(.semibold) RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 70
)
)
.frame(width: 140, height: 140)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 90, height: 90)
Image(systemName: "envelope.badge.fill")
.font(.system(size: 40, weight: .medium))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
// Title
VStack(spacing: 10) {
Text("Verify your email")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle)
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
.padding(.horizontal, 20)
}
// Code input card
VStack(spacing: 16) {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "key.fill")
.font(.system(size: 17, weight: .medium))
.foregroundColor(Color.appPrimary)
}
TextField("Enter 6-digit code", text: $viewModel.code)
.font(.system(size: 20, weight: .semibold, design: .monospaced))
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.focused($isCodeFieldFocused)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
.keyboardDismissToolbar()
.onChange(of: viewModel.code) { _, newValue in
// Limit to 6 digits
if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6))
}
// Auto-verify when 6 digits entered
if newValue.count == 6 {
viewModel.verifyEmail()
}
}
}
.padding(18)
.background(
ZStack {
Color.appBackgroundSecondary
GrainTexture(opacity: 0.01)
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 2)
)
.naturalShadow(.medium)
}
.padding(.horizontal, OrganicSpacing.comfortable)
// Error message
if let error = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(14)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.horizontal, OrganicSpacing.comfortable)
}
// Loading indicator
if viewModel.isLoading {
HStack(spacing: 10) {
ProgressView()
.tint(Color.appPrimary)
Text("Verifying...")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
// Resend code hint
HStack(spacing: 6) {
Image(systemName: "info.circle.fill")
.font(.system(size: 13))
.foregroundColor(Color.appTextSecondary.opacity(0.7))
Text("Didn't receive a code? Check your spam folder or re-register")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
.padding(.top, 8)
}
Spacer()
// Verify button
Button(action: {
viewModel.verifyEmail()
}) {
HStack(spacing: 10) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isLoading ? "Verifying..." : "Verify")
.font(.system(size: 17, weight: .semibold))
}
.frame(maxWidth: .infinity) .frame(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)
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyButton)
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
} }
.background(Color.appBackgroundPrimary)
.onAppear { .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,33 +258,40 @@ struct OnboardingVerifyEmailView: View {
var onLogout: () -> Void var onLogout: () -> Void
var body: some View { var body: some View {
VStack(spacing: 0) { ZStack {
// Navigation bar WarmGradientBackground()
HStack {
// Logout option VStack(spacing: 0) {
Button(action: onLogout) { // Navigation bar
Text("Back") HStack {
.font(.subheadline) // Logout option
Button(action: onLogout) {
HStack(spacing: 6) {
Image(systemName: "arrow.left")
.font(.system(size: 14, weight: .medium))
Text("Back")
.font(.system(size: 15, weight: .medium))
}
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
}
Spacer()
OnboardingProgressIndicator(currentStep: 4, totalSteps: 5)
Spacer()
// Invisible spacer for alignment
Text("Back")
.font(.system(size: 15, weight: .medium))
.opacity(0)
} }
.padding(.horizontal, 20)
.padding(.vertical, 12)
Spacer() OnboardingVerifyEmailContent(onVerified: onVerified)
OnboardingProgressIndicator(currentStep: 4, totalSteps: 5)
Spacer()
// Invisible spacer for alignment
Text("Back")
.font(.subheadline)
.opacity(0)
} }
.padding(.horizontal, AppSpacing.lg)
.padding(.vertical, AppSpacing.md)
OnboardingVerifyEmailContent(onVerified: onVerified)
} }
.background(Color.appBackgroundPrimary)
} }
} }

View File

@@ -7,104 +7,190 @@ 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 {
VStack(spacing: 0) { ZStack {
Spacer() WarmGradientBackground()
// Hero section // Decorative blobs
VStack(spacing: AppSpacing.xl) { GeometryReader { geo in
// App icon OrganicBlobShape(variation: 0)
Image("icon") .fill(
.resizable() RadialGradient(
.scaledToFit() colors: [
.frame(width: 120, height: 120) Color.appPrimary.opacity(0.08),
.clipShape(RoundedRectangle(cornerRadius: AppRadius.xxl)) Color.appPrimary.opacity(0.02),
.shadow(color: Color.appPrimary.opacity(0.3), radius: 20, y: 10) Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.4
)
)
.frame(width: geo.size.width * 0.7, height: geo.size.height * 0.4)
.offset(x: -geo.size.width * 0.2, y: geo.size.height * 0.1)
.blur(radius: 30)
// Welcome text OrganicBlobShape(variation: 1)
VStack(spacing: AppSpacing.sm) { .fill(
Text("Welcome to Casera") RadialGradient(
.font(.largeTitle) colors: [
.fontWeight(.bold) Color.appAccent.opacity(0.06),
.foregroundColor(Color.appTextPrimary) Color.appAccent.opacity(0.01),
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle) Color.clear
],
Text("Your home maintenance companion") center: .center,
.font(.title3) startRadius: 0,
.foregroundColor(Color.appTextSecondary) endRadius: geo.size.width * 0.3
.multilineTextAlignment(.center) )
} )
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3)
.offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65)
.blur(radius: 25)
} }
Spacer() VStack(spacing: 0) {
Spacer()
// Action buttons // Hero section
VStack(spacing: AppSpacing.md) { VStack(spacing: OrganicSpacing.comfortable) {
// Primary CTA - Start Fresh // Animated icon with glow
Button(action: onStartFresh) { ZStack {
HStack(spacing: AppSpacing.sm) { // Outer pulsing glow
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.2),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 100
)
)
.frame(width: 200, height: 200)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
// App icon
Image("icon") Image("icon")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 24, height: 24) .frame(width: 120, height: 120)
Text("Start Fresh") .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.font(.headline) .naturalShadow(.pronounced)
.fontWeight(.semibold) .scaleEffect(iconScale)
.opacity(iconOpacity)
} }
.frame(maxWidth: .infinity)
.frame(height: 56) // Welcome text
.foregroundColor(Color.appTextOnPrimary) VStack(spacing: 10) {
.background( Text("Welcome to Casera")
LinearGradient( .font(.system(size: 32, weight: .bold, design: .rounded))
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], .foregroundColor(Color.appTextPrimary)
startPoint: .topLeading, .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
endPoint: .bottomTrailing
Text("Your home maintenance companion")
.font(.system(size: 17, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
}
Spacer()
// Action buttons
VStack(spacing: 14) {
// Primary CTA - Start Fresh
Button(action: onStartFresh) {
HStack(spacing: 12) {
Image("icon")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text("Start Fresh")
.font(.system(size: 17, weight: .semibold))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
) )
) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.cornerRadius(AppRadius.md) .naturalShadow(.medium)
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
// Secondary CTA - Join Existing
Button(action: onJoinExisting) {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "person.2.fill")
.font(.title3)
Text("I have a code to join")
.font(.headline)
.fontWeight(.medium)
} }
.frame(maxWidth: .infinity) .accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
.frame(height: 56)
.foregroundColor(Color.appPrimary)
.background(Color.appPrimary.opacity(0.1))
.cornerRadius(AppRadius.md)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
// Returning user login // Secondary CTA - Join Existing
Button(action: { Button(action: onJoinExisting) {
showingLoginSheet = true HStack(spacing: 12) {
}) { Image(systemName: "person.2.fill")
Text("Already have an account? Log in") .font(.system(size: 18, weight: .medium))
.font(.subheadline) Text("I have a code to join")
.foregroundColor(Color.appTextSecondary) .font(.system(size: 17, weight: .medium))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appPrimary)
.background(Color.appPrimary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.appPrimary.opacity(0.2), lineWidth: 1)
)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
// Returning user login
Button(action: {
showingLoginSheet = true
}) {
Text("Already have an account? Log in")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
.padding(.top, 8)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton) .padding(.horizontal, OrganicSpacing.comfortable)
.padding(.top, AppSpacing.sm) .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,120 +7,213 @@ 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)
Text("Forgot Password?") ScrollView(showsIndicators: false) {
.font(.title2) VStack(spacing: OrganicSpacing.spacious) {
.fontWeight(.bold) Spacer()
.frame(height: OrganicSpacing.comfortable)
Text("Enter your email address and we'll send you a verification code") // Hero Section
.font(.subheadline) VStack(spacing: OrganicSpacing.comfortable) {
.foregroundColor(Color.appTextSecondary) ZStack {
.multilineTextAlignment(.center) Circle()
} .fill(
.frame(maxWidth: .infinity) RadialGradient(
.padding(.vertical) colors: [
} Color.appPrimary.opacity(0.15),
.listRowBackground(Color.clear) Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
// Email Input Section Image(systemName: "key.fill")
Section { .font(.system(size: 48, weight: .medium))
TextField("Email Address", text: $viewModel.email) .foregroundColor(Color.appPrimary)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.focused($isEmailFocused)
.submitLabel(.go)
.onSubmit {
viewModel.requestPasswordReset()
}
.onChange(of: viewModel.email) { _, _ in
viewModel.clearError()
}
} header: {
Text("Email")
} footer: {
Text("We'll send a 6-digit verification code to this address")
}
.listRowBackground(Color.appBackgroundSecondary)
// Error/Success Messages
if let errorMessage = viewModel.errorMessage {
Section {
Label {
Text(errorMessage)
.foregroundColor(Color.appError)
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
if let successMessage = viewModel.successMessage {
Section {
Label {
Text(successMessage)
.foregroundColor(Color.appAccent)
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appAccent)
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
// Send Code Button
Section {
Button(action: {
viewModel.requestPasswordReset()
}) {
HStack {
Spacer()
if viewModel.isLoading {
ProgressView()
} else {
Label("Send Reset Code", systemImage: "envelope.fill")
.fontWeight(.semibold)
} }
Spacer()
}
}
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
Button(action: { VStack(spacing: 8) {
dismiss() Text("Forgot Password?")
}) { .font(.system(size: 26, weight: .bold, design: .rounded))
HStack { .foregroundColor(Color.appTextPrimary)
Spacer()
Text("Back to Login") Text("Enter your email address and we'll send you a verification code")
.foregroundColor(Color.appTextSecondary) .font(.system(size: 15, weight: .medium))
Spacer() .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
} }
// Form Card
VStack(spacing: 20) {
// Email Field
VStack(alignment: .leading, spacing: 8) {
Text("EMAIL")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: "envelope.fill")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
}
TextField("Email Address", text: $viewModel.email)
.font(.system(size: 16, weight: .medium))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.focused($isEmailFocused)
.submitLabel(.go)
.onSubmit {
viewModel.requestPasswordReset()
}
.onChange(of: viewModel.email) { _, _ in
viewModel.clearError()
}
}
.padding(16)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isEmailFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
.animation(.easeInOut(duration: 0.2), value: isEmailFocused)
Text("We'll send a 6-digit verification code to this address")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Success Message
if let successMessage = viewModel.successMessage {
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appAccent)
Text(successMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appAccent)
Spacer()
}
.padding(16)
.background(Color.appAccent.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Send Code Button
Button(action: {
viewModel.requestPasswordReset()
}) {
HStack(spacing: 8) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "envelope.fill")
}
Text(viewModel.isLoading ? "Sending..." : "Send Reset Code")
.font(.headline)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
!viewModel.email.isEmpty && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(
color: !viewModel.email.isEmpty && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
radius: 10,
y: 5
)
}
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
// Back to Login
Button(action: { dismiss() }) {
Text("Back to Login")
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
}
.padding(.top, 8)
}
.padding(OrganicSpacing.cozy)
.background(OrganicFormCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
} }
} }
.listRowBackground(Color.appBackgroundSecondary)
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("Reset Password")
.navigationBarTitleDisplayMode(.inline) .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)
Text("Check Your Email") ScrollView(showsIndicators: false) {
.font(.title2) VStack(spacing: OrganicSpacing.spacious) {
.fontWeight(.bold) Spacer()
.foregroundColor(Color.appTextPrimary) .frame(height: OrganicSpacing.comfortable)
Text("We sent a 6-digit code to") // Hero Section
.font(.subheadline) VStack(spacing: OrganicSpacing.comfortable) {
.foregroundColor(Color.appTextSecondary) ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Text(viewModel.email) Image(systemName: "envelope.badge.fill")
.font(.subheadline) .font(.system(size: 48, weight: .medium))
.fontWeight(.semibold) .foregroundColor(Color.appPrimary)
.foregroundColor(Color.appTextPrimary)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
// Info Section
Section {
Label {
Text("Code expires in 15 minutes")
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
} icon: {
Image(systemName: "clock.fill")
.foregroundColor(Color.appAccent)
}
}
.listRowBackground(Color.appBackgroundSecondary)
// Code Input Section
Section {
TextField("000000", text: $viewModel.code)
.font(.system(size: 32, weight: .semibold, design: .rounded))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.focused($isCodeFocused)
.keyboardDismissToolbar()
.onChange(of: viewModel.code) { _, newValue in
// Limit to 6 digits
if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6))
} }
// Only allow numbers
viewModel.code = newValue.filter { $0.isNumber }
viewModel.clearError()
}
} header: {
Text("Verification Code")
} footer: {
Text("Enter the 6-digit code from your email")
}
.listRowBackground(Color.appBackgroundSecondary)
// Error/Success Messages VStack(spacing: 8) {
if let errorMessage = viewModel.errorMessage { Text("Check Your Email")
Section { .font(.system(size: 26, weight: .bold, design: .rounded))
Label { .foregroundColor(Color.appTextPrimary)
Text(errorMessage)
.foregroundColor(Color.appError)
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
if let successMessage = viewModel.successMessage { Text("We sent a 6-digit code to")
Section { .font(.system(size: 15, weight: .medium))
Label { .foregroundColor(Color.appTextSecondary)
Text(successMessage)
.foregroundColor(Color.appPrimary)
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appPrimary)
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
// Verify Button Text(viewModel.email)
Section { .font(.system(size: 15, weight: .bold, design: .rounded))
Button(action: { .foregroundColor(Color.appPrimary)
viewModel.verifyResetCode()
}) {
HStack {
Spacer()
if viewModel.isLoading {
ProgressView()
} else {
Label("Verify Code", systemImage: "checkmark.shield.fill")
.fontWeight(.semibold)
} }
Spacer()
}
}
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
}
.listRowBackground(Color.appBackgroundSecondary)
// Help Section
Section {
VStack(spacing: 12) {
Text("Didn't receive the code?")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
Button(action: {
// Clear code and go back to request new one
viewModel.code = ""
viewModel.clearError()
viewModel.currentStep = .requestCode
}) {
Text("Send New Code")
.font(.subheadline)
.fontWeight(.semibold)
} }
Text("Check your spam folder if you don't see it") // Form Card
.font(.caption) VStack(spacing: 20) {
.foregroundColor(Color.appTextSecondary) // Timer Info
.multilineTextAlignment(.center) HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appAccent.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "clock.fill")
.font(.system(size: 18, weight: .medium))
.foregroundColor(Color.appAccent)
}
Text("Code expires in 15 minutes")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
Spacer()
}
.padding(16)
.background(Color.appAccent.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
// Code Input
VStack(alignment: .leading, spacing: 8) {
Text("VERIFICATION CODE")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
TextField("000000", text: $viewModel.code)
.font(.system(size: 32, weight: .bold, design: .rounded))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.focused($isCodeFocused)
.keyboardDismissToolbar()
.padding(20)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
.onChange(of: viewModel.code) { _, newValue in
if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6))
}
viewModel.code = newValue.filter { $0.isNumber }
viewModel.clearError()
}
Text("Enter the 6-digit code from your email")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Success Message
if let successMessage = viewModel.successMessage {
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appPrimary)
Text(successMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
Spacer()
}
.padding(16)
.background(Color.appPrimary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Verify Button
Button(action: {
viewModel.verifyResetCode()
}) {
HStack(spacing: 8) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "checkmark.shield.fill")
}
Text(viewModel.isLoading ? "Verifying..." : "Verify Code")
.font(.headline)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
viewModel.code.count == 6 && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(
color: viewModel.code.count == 6 && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
radius: 10,
y: 5
)
}
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
OrganicDivider()
.padding(.vertical, 4)
// Help Section
VStack(spacing: 12) {
Text("Didn't receive the code?")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
Button(action: {
viewModel.code = ""
viewModel.clearError()
viewModel.currentStep = .requestCode
}) {
Text("Send New Code")
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary)
}
Text("Check your spam folder if you don't see it")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
}
.padding(OrganicSpacing.cozy)
.background(OrganicVerifyCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
} }
.frame(maxWidth: .infinity)
} }
.listRowBackground(Color.clear)
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("Verify Code")
.navigationBarTitleDisplayMode(.inline) .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)
Text(L10n.Auth.joinCasera) ScrollView(showsIndicators: false) {
.font(.largeTitle) VStack(spacing: OrganicSpacing.spacious) {
.fontWeight(.bold) Spacer()
.frame(height: OrganicSpacing.comfortable)
Text(L10n.Auth.startManaging) // Hero Section
.font(.subheadline) VStack(spacing: OrganicSpacing.comfortable) {
.foregroundColor(Color.appTextSecondary) ZStack {
} Circle()
.frame(maxWidth: .infinity) .fill(
.padding(.vertical) RadialGradient(
} colors: [
.listRowBackground(Color.clear) Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Section { Image(systemName: "person.badge.plus")
TextField(L10n.Auth.registerUsername, text: $viewModel.username) .font(.system(size: 48, weight: .medium))
.textInputAutocapitalization(.never) .foregroundColor(Color.appPrimary)
.autocorrectionDisabled() }
.textContentType(.username)
.focused($focusedField, equals: .username) VStack(spacing: 8) {
.submitLabel(.next) Text(L10n.Auth.joinCasera)
.onSubmit { .font(.system(size: 26, weight: .bold, design: .rounded))
focusedField = .email .foregroundColor(Color.appTextPrimary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField) Text(L10n.Auth.startManaging)
.font(.system(size: 15, weight: .medium))
TextField(L10n.Auth.registerEmail, text: $viewModel.email) .foregroundColor(Color.appTextSecondary)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.focused($focusedField, equals: .email)
.submitLabel(.next)
.onSubmit {
focusedField = .password
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField)
} header: {
Text(L10n.Auth.accountInfo)
}
.listRowBackground(Color.appBackgroundSecondary)
Section {
// Using .newPassword enables iOS Strong Password generation
// iOS will automatically offer to save to iCloud Keychain after successful registration
SecureField(L10n.Auth.registerPassword, text: $viewModel.password)
.textContentType(.newPassword)
.focused($focusedField, equals: .password)
.submitLabel(.next)
.onSubmit {
focusedField = .confirmPassword
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField)
SecureField(L10n.Auth.registerConfirmPassword, text: $viewModel.confirmPassword)
.textContentType(.newPassword)
.focused($focusedField, equals: .confirmPassword)
.submitLabel(.go)
.onSubmit {
viewModel.register()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField)
} header: {
Text(L10n.Auth.security)
} footer: {
Text(L10n.Auth.passwordSuggestion)
}
.listRowBackground(Color.appBackgroundSecondary)
if let errorMessage = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.foregroundColor(Color.appError)
.font(.subheadline)
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
Section {
Button(action: viewModel.register) {
HStack {
Spacer()
if viewModel.isLoading {
ProgressView()
} else {
Text(L10n.Auth.registerButton)
.fontWeight(.semibold)
} }
Spacer()
} }
// Registration Card
VStack(spacing: 20) {
// Username Field
OrganicTextField(
label: L10n.Auth.accountInfo,
placeholder: L10n.Auth.registerUsername,
text: $viewModel.username,
icon: "person.fill",
isFocused: focusedField == .username
)
.focused($focusedField, equals: .username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textContentType(.username)
.submitLabel(.next)
.onSubmit { focusedField = .email }
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField)
// Email Field
OrganicTextField(
label: nil,
placeholder: L10n.Auth.registerEmail,
text: $viewModel.email,
icon: "envelope.fill",
isFocused: focusedField == .email
)
.focused($focusedField, equals: .email)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.submitLabel(.next)
.onSubmit { focusedField = .password }
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField)
OrganicDivider()
.padding(.vertical, 4)
// Password Field
OrganicSecureField(
label: L10n.Auth.security,
placeholder: L10n.Auth.registerPassword,
text: $viewModel.password,
isVisible: $isPasswordVisible,
isFocused: focusedField == .password
)
.focused($focusedField, equals: .password)
.textContentType(.newPassword)
.submitLabel(.next)
.onSubmit { focusedField = .confirmPassword }
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField)
// Confirm Password Field
OrganicSecureField(
label: nil,
placeholder: L10n.Auth.registerConfirmPassword,
text: $viewModel.confirmPassword,
isVisible: $isConfirmPasswordVisible,
isFocused: focusedField == .confirmPassword
)
.focused($focusedField, equals: .confirmPassword)
.textContentType(.newPassword)
.submitLabel(.go)
.onSubmit { viewModel.register() }
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField)
Text(L10n.Auth.passwordSuggestion)
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Register Button
Button(action: viewModel.register) {
HStack(spacing: 8) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isLoading ? L10n.Auth.creatingAccount : L10n.Auth.registerButton)
.font(.headline)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
isFormValid && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(
color: isFormValid && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
radius: 10,
y: 5
)
}
.disabled(!isFormValid || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton)
// Login Link
HStack(spacing: 6) {
Text(L10n.Auth.alreadyHaveAccount)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
Button(L10n.Auth.signIn) {
dismiss()
}
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary)
}
.padding(.top, 8)
}
.padding(OrganicSpacing.cozy)
.background(OrganicFormBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
} }
.disabled(viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton)
} }
.listRowBackground(Color.appBackgroundSecondary)
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Auth.registerTitle)
.navigationBarTitleDisplayMode(.inline) .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)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.onChange(of: shareCode) { newValue in
// Limit to 6 characters and uppercase
if newValue.count > 6 {
shareCode = String(newValue.prefix(6))
}
shareCode = shareCode.uppercased()
viewModel.clearError()
}
.disabled(viewModel.isLoading)
} header: {
Text(L10n.Residences.enterShareCode)
} footer: {
Text(L10n.Residences.shareCodeFooter)
.foregroundColor(Color.appTextSecondary)
}
.listRowBackground(Color.appBackgroundSecondary)
if let error = viewModel.errorMessage { ScrollView(showsIndicators: false) {
Section { VStack(spacing: OrganicSpacing.spacious) {
Text(error) Spacer()
.foregroundColor(Color.appError) .frame(height: OrganicSpacing.comfortable)
}
.listRowBackground(Color.appBackgroundSecondary)
}
Section { // Hero Section
Button(action: joinResidence) { VStack(spacing: OrganicSpacing.comfortable) {
HStack { ZStack {
Spacer() Circle()
if viewModel.isLoading { .fill(
ProgressView() RadialGradient(
.progressViewStyle(CircularProgressViewStyle()) colors: [
} else { Color.appPrimary.opacity(0.15),
Text(L10n.Residences.joinButton) Color.appPrimary.opacity(0.05),
.fontWeight(.semibold) Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Image(systemName: "person.badge.plus")
.font(.system(size: 48, weight: .medium))
.foregroundColor(Color.appPrimary)
}
VStack(spacing: 8) {
Text(L10n.Residences.joinTitle)
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text(L10n.Residences.enterShareCode)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
} }
Spacer()
} }
// Form Card
VStack(spacing: 20) {
// Share Code Input
VStack(alignment: .leading, spacing: 8) {
Text(L10n.Residences.shareCode.uppercased())
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
TextField("ABC123", text: $shareCode)
.font(.system(size: 32, weight: .bold, design: .rounded))
.multilineTextAlignment(.center)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.focused($isCodeFocused)
.disabled(viewModel.isLoading)
.padding(20)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
.onChange(of: shareCode) { newValue in
if newValue.count > 6 {
shareCode = String(newValue.prefix(6))
}
shareCode = shareCode.uppercased()
viewModel.clearError()
}
Text(L10n.Residences.shareCodeFooter)
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
// Error Message
if let error = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Join Button
Button(action: joinResidence) {
HStack(spacing: 8) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "person.badge.plus")
}
Text(viewModel.isLoading ? "Joining..." : L10n.Residences.joinButton)
.font(.headline)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
shareCode.count == 6 && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(
color: shareCode.count == 6 && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
radius: 10,
y: 5
)
}
.disabled(shareCode.count != 6 || viewModel.isLoading)
// Cancel Button
Button(action: { dismiss() }) {
Text(L10n.Common.cancel)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
}
.disabled(viewModel.isLoading)
.padding(.top, 8)
}
.padding(OrganicSpacing.cozy)
.background(OrganicJoinCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
} }
.disabled(shareCode.count != 6 || viewModel.isLoading)
} }
.listRowBackground(Color.appBackgroundSecondary)
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Residences.joinTitle)
.navigationBarTitleDisplayMode(.inline) .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,172 +60,256 @@ struct ResidenceFormView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
Form { ZStack {
Section { WarmGradientBackground()
TextField(L10n.Residences.propertyName, text: $name)
.focused($focusedField, equals: .name)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField)
if !nameError.isEmpty { ScrollView(showsIndicators: false) {
Text(nameError) VStack(spacing: OrganicSpacing.comfortable) {
.font(.caption) // Property Details Section
.foregroundColor(Color.appError) OrganicFormSection(title: L10n.Residences.propertyDetails, icon: "house.fill") {
} VStack(spacing: 16) {
OrganicFormTextField(
Picker(L10n.Residences.propertyType, selection: $selectedPropertyType) { label: L10n.Residences.propertyName,
Text(L10n.Residences.selectType).tag(nil as ResidenceType?) placeholder: "My Home",
ForEach(residenceTypes, id: \.id) { type in text: $name,
Text(type.name).tag(type as ResidenceType?) error: nameError.isEmpty ? nil : nameError
}
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
} header: {
Text(L10n.Residences.propertyDetails)
} footer: {
Text(L10n.Residences.requiredName)
.font(.caption)
.foregroundColor(Color.appError)
}
.listRowBackground(Color.appBackgroundSecondary)
Section {
TextField(L10n.Residences.streetAddress, text: $streetAddress)
.focused($focusedField, equals: .streetAddress)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField)
TextField(L10n.Residences.apartmentUnit, text: $apartmentUnit)
.focused($focusedField, equals: .apartmentUnit)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField)
TextField(L10n.Residences.city, text: $city)
.focused($focusedField, equals: .city)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField)
TextField(L10n.Residences.stateProvince, text: $stateProvince)
.focused($focusedField, equals: .stateProvince)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField)
TextField(L10n.Residences.postalCode, text: $postalCode)
.focused($focusedField, equals: .postalCode)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField)
TextField(L10n.Residences.country, text: $country)
.focused($focusedField, equals: .country)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
} header: {
Text(L10n.Residences.address)
}
.listRowBackground(Color.appBackgroundSecondary)
Section(header: Text(L10n.Residences.propertyFeatures)) {
HStack {
Text(L10n.Residences.bedrooms)
Spacer()
TextField("0", text: $bedrooms)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
.focused($focusedField, equals: .bedrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField)
}
HStack {
Text(L10n.Residences.bathrooms)
Spacer()
TextField("0.0", text: $bathrooms)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
.focused($focusedField, equals: .bathrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField)
}
TextField(L10n.Residences.squareFootage, text: $squareFootage)
.keyboardType(.numberPad)
.focused($focusedField, equals: .squareFootage)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField)
TextField(L10n.Residences.lotSize, text: $lotSize)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .lotSize)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField)
TextField(L10n.Residences.yearBuilt, text: $yearBuilt)
.keyboardType(.numberPad)
.focused($focusedField, equals: .yearBuilt)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField)
}
.listRowBackground(Color.appBackgroundSecondary)
.keyboardDismissToolbar()
Section(header: Text(L10n.Residences.additionalDetails)) {
TextField(L10n.Residences.description, text: $description, axis: .vertical)
.lineLimit(3...6)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField)
.keyboardDismissToolbar()
Toggle(L10n.Residences.primaryResidence, isOn: $isPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
}
.listRowBackground(Color.appBackgroundSecondary)
// Users section (edit mode only, owner only)
if isEditMode && isCurrentUserOwner {
Section {
if isLoadingUsers {
HStack {
Spacer()
ProgressView()
Spacer()
}
} else if users.isEmpty {
Text("No shared users")
.foregroundColor(.secondary)
} else {
ForEach(users, id: \.id) { user in
UserRow(
user: user,
isOwner: user.id == existingResidence?.ownerId,
onRemove: {
userToRemove = user
showRemoveUserConfirmation = true
}
) )
.focused($focusedField, equals: .name)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField)
OrganicFormPicker(
label: L10n.Residences.propertyType,
selection: $selectedPropertyType,
options: residenceTypes,
optionLabel: { $0.name },
placeholder: L10n.Residences.selectType
)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
} }
} }
} header: { .padding(.top, 8)
Text("Shared Users (\(users.count))")
} footer: {
Text("Users with access to this residence. Use the share button to invite others.")
}
.listRowBackground(Color.appBackgroundSecondary)
}
if let errorMessage = viewModel.errorMessage { // Address Section
Section { OrganicFormSection(title: L10n.Residences.address, icon: "mappin.circle.fill") {
Text(errorMessage) VStack(spacing: 16) {
.foregroundColor(Color.appError) OrganicFormTextField(
.font(.caption) label: L10n.Residences.streetAddress,
placeholder: "123 Main St",
text: $streetAddress
)
.focused($focusedField, equals: .streetAddress)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField)
OrganicFormTextField(
label: L10n.Residences.apartmentUnit,
placeholder: "Apt 4B",
text: $apartmentUnit
)
.focused($focusedField, equals: .apartmentUnit)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField)
HStack(spacing: 12) {
OrganicFormTextField(
label: L10n.Residences.city,
placeholder: "City",
text: $city
)
.focused($focusedField, equals: .city)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField)
OrganicFormTextField(
label: L10n.Residences.stateProvince,
placeholder: "State",
text: $stateProvince
)
.focused($focusedField, equals: .stateProvince)
.frame(maxWidth: 120)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField)
}
HStack(spacing: 12) {
OrganicFormTextField(
label: L10n.Residences.postalCode,
placeholder: "12345",
text: $postalCode
)
.focused($focusedField, equals: .postalCode)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField)
OrganicFormTextField(
label: L10n.Residences.country,
placeholder: "USA",
text: $country
)
.focused($focusedField, equals: .country)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
}
}
}
// Property Features Section
OrganicFormSection(title: L10n.Residences.propertyFeatures, icon: "square.grid.2x2.fill") {
VStack(spacing: 16) {
HStack(spacing: 12) {
OrganicFormTextField(
label: L10n.Residences.bedrooms,
placeholder: "0",
text: $bedrooms,
keyboardType: .numberPad
)
.focused($focusedField, equals: .bedrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField)
OrganicFormTextField(
label: L10n.Residences.bathrooms,
placeholder: "0.0",
text: $bathrooms,
keyboardType: .decimalPad
)
.focused($focusedField, equals: .bathrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField)
}
HStack(spacing: 12) {
OrganicFormTextField(
label: L10n.Residences.squareFootage,
placeholder: "sq ft",
text: $squareFootage,
keyboardType: .numberPad
)
.focused($focusedField, equals: .squareFootage)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField)
OrganicFormTextField(
label: L10n.Residences.lotSize,
placeholder: "acres",
text: $lotSize,
keyboardType: .decimalPad
)
.focused($focusedField, equals: .lotSize)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField)
}
OrganicFormTextField(
label: L10n.Residences.yearBuilt,
placeholder: "2020",
text: $yearBuilt,
keyboardType: .numberPad
)
.focused($focusedField, equals: .yearBuilt)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField)
}
}
// Additional Details Section
OrganicFormSection(title: L10n.Residences.additionalDetails, icon: "text.alignleft") {
VStack(spacing: 16) {
OrganicFormTextArea(
label: L10n.Residences.description,
placeholder: "Add notes about your property...",
text: $description
)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField)
OrganicFormToggle(
label: L10n.Residences.primaryResidence,
isOn: $isPrimary,
icon: "star.fill"
)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
}
}
// Users Section (edit mode only, owner only)
if isEditMode && isCurrentUserOwner {
OrganicFormSection(title: "Shared Users (\(users.count))", icon: "person.2.fill") {
VStack(spacing: 12) {
if isLoadingUsers {
HStack {
Spacer()
ProgressView()
Spacer()
}
.padding(.vertical, 20)
} else if users.isEmpty {
Text("No shared users")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.padding(.vertical, 12)
} else {
ForEach(users, id: \.id) { user in
OrganicUserRow(
user: user,
isOwner: user.id == existingResidence?.ownerId,
onRemove: {
userToRemove = user
showRemoveUserConfirmation = true
}
)
}
}
Text("Use the share button to invite others")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal, 16)
}
Spacer()
.frame(height: 40)
} }
.listRowBackground(Color.appBackgroundSecondary) .padding(.horizontal, 16)
} }
.keyboardDismissToolbar()
} }
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(isEditMode ? L10n.Residences.editTitle : L10n.Residences.addTitle) .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 {
Text(title) Circle()
.font(.title2.weight(.bold)) .fill(
.foregroundColor(Color.appTextPrimary) LinearGradient(
.multilineTextAlignment(.center) colors: [Color.appAccent, Color.appAccent.opacity(0.8)],
.padding(.horizontal) startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
// Message Image(systemName: "star.fill")
Text(message) .font(.system(size: 36, weight: .medium))
.font(.body) .foregroundColor(.white)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
// Pro Features Preview - Dynamic content or fallback
Group {
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
PromoContentView(content: promoContent)
.padding()
} else {
// Fallback to static features if no promo content
VStack(alignment: .leading, spacing: AppSpacing.md) {
FeatureRow(icon: "house.fill", text: "Unlimited properties")
FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
FeatureRow(icon: "person.2.fill", text: "Contractor management")
FeatureRow(icon: "doc.fill", text: "Document & warranty storage")
} }
.padding() .naturalShadow(.pronounced)
}
.padding(.top, OrganicSpacing.comfortable)
VStack(spacing: 8) {
Text(title)
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
Text(message)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
} }
} }
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg) // Features Card
.padding(.horizontal) VStack(spacing: 16) {
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
PromoContentView(content: promoContent)
} else {
VStack(alignment: .leading, spacing: 14) {
OrganicUpgradeFeatureRow(icon: "house.fill", text: "Unlimited properties")
OrganicUpgradeFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
OrganicUpgradeFeatureRow(icon: "person.2.fill", text: "Contractor management")
OrganicUpgradeFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
}
}
}
.padding(OrganicSpacing.cozy)
.background(OrganicUpgradeCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.naturalShadow(.medium)
.padding(.horizontal, 16)
// Subscription Products // Subscription Products
if storeKit.isLoading { VStack(spacing: 12) {
ProgressView() if storeKit.isLoading {
.tint(Color.appPrimary) ProgressView()
.padding() .tint(Color.appPrimary)
} else if !storeKit.products.isEmpty { .padding()
VStack(spacing: AppSpacing.md) { } else if !storeKit.products.isEmpty {
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 {
} }
) )
} }
} } else {
.padding(.horizontal) Button(action: {
} else { Task { await storeKit.loadProducts() }
// Fallback upgrade button if products fail to load }) {
Button(action: { HStack(spacing: 8) {
Task { await storeKit.loadProducts() } Image(systemName: "arrow.clockwise")
}) {
HStack {
if isProcessing {
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(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
.frame(maxWidth: .infinity)
.foregroundColor(Color.appTextOnPrimary)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppRadius.md)
} }
.disabled(isProcessing)
.padding(.horizontal)
} }
.padding(.horizontal, 16)
// Error Message // 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
Button(action: { VStack(spacing: 12) {
showFeatureComparison = true Button(action: {
}) { showFeatureComparison = true
Text("Compare Free vs Pro") }) {
.font(.subheadline) Text("Compare Free vs Pro")
.foregroundColor(Color.appPrimary) .font(.system(size: 15, weight: .semibold))
} .foregroundColor(Color.appPrimary)
}
// Restore Purchases Button(action: {
Button(action: { handleRestore()
handleRestore() }) {
}) { Text("Restore Purchases")
Text("Restore Purchases") .font(.system(size: 13, weight: .medium))
.font(.caption) .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) {
Image(systemName: "checkmark") ZStack {
.font(.system(size: 14, weight: .bold)) Circle()
.foregroundColor(Color.appPrimary) .fill(Color.appPrimary.opacity(0.1))
.frame(width: 24, height: 24)
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(Color.appPrimary)
}
Text(text) 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,133 +135,171 @@ 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) {
Text(triggerData?.title ?? "Upgrade to Pro") VStack(spacing: OrganicSpacing.comfortable) {
.font(.title2.weight(.bold)) // Hero Section
.foregroundColor(Color.appTextPrimary) VStack(spacing: OrganicSpacing.comfortable) {
.multilineTextAlignment(.center) ZStack {
.padding(.horizontal) Circle()
.fill(
RadialGradient(
colors: [
Color.appAccent.opacity(0.2),
Color.appAccent.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 80
)
)
.frame(width: 160, height: 160)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
// Message ZStack {
Text(triggerData?.message ?? "Unlock unlimited access to all features") Circle()
.font(.body) .fill(
.foregroundColor(Color.appTextSecondary) LinearGradient(
.multilineTextAlignment(.center) colors: [Color.appAccent, Color.appAccent.opacity(0.8)],
.padding(.horizontal) startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
// Pro Features Preview - Dynamic content or fallback Image(systemName: "star.fill")
Group { .font(.system(size: 36, weight: .medium))
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty { .foregroundColor(.white)
PromoContentView(content: promoContent) }
.padding() .naturalShadow(.pronounced)
} else {
// Fallback to static features if no promo content
VStack(alignment: .leading, spacing: AppSpacing.md) {
FeatureRow(icon: "house.fill", text: "Unlimited properties")
FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
FeatureRow(icon: "person.2.fill", text: "Contractor management")
FeatureRow(icon: "doc.fill", text: "Document & warranty storage")
} }
.padding() .padding(.top, OrganicSpacing.comfortable)
}
}
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
.padding(.horizontal)
// Subscription Products VStack(spacing: 8) {
if storeKit.isLoading { Text(triggerData?.title ?? "Upgrade to Pro")
ProgressView() .font(.system(size: 26, weight: .bold, design: .rounded))
.tint(Color.appPrimary) .foregroundColor(Color.appTextPrimary)
.padding() .multilineTextAlignment(.center)
} else if !storeKit.products.isEmpty {
VStack(spacing: AppSpacing.md) { Text(triggerData?.message ?? "Unlock unlimited access to all features")
ForEach(storeKit.products, id: \.id) { product in .font(.system(size: 15, weight: .medium))
SubscriptionProductButton( .foregroundColor(Color.appTextSecondary)
product: product, .multilineTextAlignment(.center)
isSelected: selectedProduct?.id == product.id, .padding(.horizontal)
isProcessing: isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
}
)
} }
} }
.padding(.horizontal)
} else { // Features Card
// Fallback upgrade button if products fail to load VStack(spacing: 16) {
Button(action: { if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
Task { await storeKit.loadProducts() } PromoContentView(content: promoContent)
}) { } else {
HStack { VStack(alignment: .leading, spacing: 14) {
if isProcessing { OrganicFeatureRow(icon: "house.fill", text: "Unlimited properties")
ProgressView() OrganicFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
.tint(Color.appTextOnPrimary) OrganicFeatureRow(icon: "person.2.fill", text: "Contractor management")
} else { OrganicFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
Text("Retry Loading Products")
.fontWeight(.semibold)
} }
} }
.frame(maxWidth: .infinity)
.foregroundColor(Color.appTextOnPrimary)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppRadius.md)
} }
.disabled(isProcessing) .padding(OrganicSpacing.cozy)
.padding(.horizontal) .background(OrganicCardBackground())
} .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.naturalShadow(.medium)
.padding(.horizontal, 16)
// Error Message // Subscription Products
if let error = errorMessage { VStack(spacing: 12) {
HStack { if storeKit.isLoading {
Image(systemName: "exclamationmark.triangle.fill") ProgressView()
.foregroundColor(Color.appError) .tint(Color.appPrimary)
Text(error) .padding()
.font(.subheadline) } else if !storeKit.products.isEmpty {
.foregroundColor(Color.appError) ForEach(storeKit.products, id: \.id) { product in
OrganicSubscriptionButton(
product: product,
isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
}
)
}
} else {
Button(action: {
Task { await storeKit.loadProducts() }
}) {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
Text("Retry Loading Products")
.font(.system(size: 16, weight: .semibold))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
} }
.padding() .padding(.horizontal, 16)
.background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md)
.padding(.horizontal)
}
// Compare Plans // Error Message
Button(action: { if let error = errorMessage {
showFeatureComparison = true HStack(spacing: 10) {
}) { Image(systemName: "exclamationmark.circle.fill")
Text("Compare Free vs Pro") .foregroundColor(Color.appError)
.font(.subheadline) Text(error)
.foregroundColor(Color.appPrimary) .font(.system(size: 14, weight: .medium))
} .foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal, 16)
}
// Restore Purchases // Links
Button(action: { VStack(spacing: 12) {
handleRestore() Button(action: {
}) { showFeatureComparison = true
Text("Restore Purchases") }) {
.font(.caption) Text("Compare Free vs Pro")
.foregroundColor(Color.appTextSecondary) .font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
Button(action: {
handleRestore()
}) {
Text("Restore Purchases")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
.padding(.bottom, OrganicSpacing.airy)
} }
.padding(.bottom, AppSpacing.xl)
} }
} }
.background(Color.appBackgroundPrimary)
.navigationBarTitleDisplayMode(.inline) .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,60 +523,21 @@ 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 {
let icon: String let icon: String
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) {
Image(systemName: "exclamationmark.triangle") ZStack {
.font(.system(size: 64)) Circle()
.foregroundColor(Color.appError) .fill(Color.appError.opacity(0.1))
.frame(width: 100, height: 100)
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 44, weight: .medium))
.foregroundColor(Color.appError)
}
Text("Error: \(message)") 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) {
Image(systemName: "house") ZStack {
.font(.system(size: 80)) Circle()
.foregroundColor(Color.appPrimary.opacity(0.6)) .fill(Color.appPrimary.opacity(0.08))
.frame(width: 120, height: 120)
Image(systemName: "house")
.font(.system(size: 56, weight: .medium))
.foregroundColor(Color.appPrimary.opacity(0.6))
}
Text("No properties yet") 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) {
Image(systemName: icon) ZStack {
.font(.title3) Circle()
.foregroundColor(Color.appPrimary) .fill(Color.appPrimary.opacity(0.1))
.frame(width: 44, height: 44)
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
Text(value) 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) {
Image(systemName: "checkmark.circle") ZStack {
.font(.system(size: 48)) Circle()
.foregroundColor(Color.appTextSecondary.opacity(0.5)) .fill(Color.appPrimary.opacity(0.08))
.frame(width: 80, height: 80)
Image(systemName: "checkmark.circle")
.font(.system(size: 36, weight: .medium))
.foregroundColor(Color.appPrimary.opacity(0.5))
}
Text("No tasks yet") 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,9 +154,8 @@ 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()
} else if let error = tasksError { } else if let error = tasksError {
@@ -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) {
Button(action: { HStack(spacing: 12) {
// Check if we should show upgrade prompt before adding Button(action: {
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") { loadAllTasks(forceRefresh: true)
showingUpgradePrompt = true }) {
} else { Image(systemName: "arrow.clockwise")
showAddTask = true .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)
Image(systemName: "plus")
Button(action: {
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
showingUpgradePrompt = true
} else {
showAddTask = true
}
}) {
OrganicToolbarAddButton()
}
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
} }
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
loadAllTasks(forceRefresh: true)
}) {
Image(systemName: "arrow.clockwise")
.rotationEffect(.degrees(isLoadingTasks ? 360 : 0))
.animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks)
}
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true || isLoadingTasks)
} }
} }
.onChange(of: taskViewModel.isLoading) { isLoading in .onChange(of: taskViewModel.isLoading) { isLoading in
@@ -347,7 +281,7 @@ struct AllTasksView: View {
} }
} }
} }
private func loadAllTasks(forceRefresh: Bool = false) { private func loadAllTasks(forceRefresh: Bool = false) {
taskViewModel.loadTasks(forceRefresh: forceRefresh) taskViewModel.loadTasks(forceRefresh: forceRefresh)
} }
@@ -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))
@@ -393,7 +451,7 @@ extension View {
struct RoundedCorner: Shape { struct RoundedCorner: Shape {
var radius: CGFloat = .infinity var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners var corners: UIRectCorner = .allCorners
func path(in rect: CGRect) -> Path { func path(in rect: CGRect) -> Path {
let path = UIBezierPath( let path = UIBezierPath(
roundedRect: rect, roundedRect: rect,
@@ -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)
// Header // Hero Section
VStack(spacing: 12) { VStack(spacing: OrganicSpacing.comfortable) {
Image(systemName: "envelope.badge.shield.half.filled") ZStack {
.font(.system(size: 60)) Circle()
.foregroundStyle(Color.appPrimary.gradient) .fill(
.padding(.bottom, 8) RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Text(L10n.Auth.verifyYourEmail) Image(systemName: "envelope.badge.shield.half.filled")
.font(.title) .font(.system(size: 48, weight: .medium))
.fontWeight(.bold) .foregroundColor(Color.appPrimary)
.foregroundColor(Color.appTextPrimary) }
Text(L10n.Auth.verifyMustVerify) VStack(spacing: 8) {
.font(.subheadline) Text(L10n.Auth.verifyYourEmail)
.foregroundColor(Color.appTextSecondary) .font(.system(size: 26, weight: .bold, design: .rounded))
.multilineTextAlignment(.center) .foregroundColor(Color.appTextPrimary)
.padding(.horizontal)
Text(L10n.Auth.verifyMustVerify)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
} }
// Info Card // Form Card
GroupBox { VStack(spacing: 20) {
// Info Banner
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "exclamationmark.shield.fill") ZStack {
.foregroundColor(Color.appAccent) Circle()
.font(.title2) .fill(Color.appAccent.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "exclamationmark.shield.fill")
.font(.system(size: 18, weight: .medium))
.foregroundColor(Color.appAccent)
}
Text(L10n.Auth.verifyCheckInbox) 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) .focused($isFocused)
.frame(height: 60) .keyboardDismissToolbar()
.padding(.horizontal) .padding(20)
.focused($isFocused) .background(Color.appBackgroundPrimary.opacity(0.5))
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verificationCodeField) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.keyboardDismissToolbar() .overlay(
.onChange(of: viewModel.code) { _, newValue in RoundedRectangle(cornerRadius: 16, style: .continuous)
// Limit to 6 digits .stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
if newValue.count > 6 { )
viewModel.code = String(newValue.prefix(6)) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verificationCodeField)
.onChange(of: viewModel.code) { _, newValue in
if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6))
}
viewModel.code = newValue.filter { $0.isNumber }
} }
// Only allow numbers
viewModel.code = newValue.filter { $0.isNumber } Text(L10n.Auth.verifyCodeMustBe6)
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
} }
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
Text(L10n.Auth.verifyCodeMustBe6) // Verify Button
.font(.caption) Button(action: {
.foregroundColor(Color.appTextSecondary) viewModel.verifyEmail()
.padding(.horizontal) }) {
} HStack(spacing: 8) {
if viewModel.isLoading {
// Error Message ProgressView()
if let errorMessage = viewModel.errorMessage { .progressViewStyle(CircularProgressViewStyle(tint: .white))
ErrorMessageView(message: errorMessage, onDismiss: viewModel.clearError) } else {
.padding(.horizontal) Image(systemName: "checkmark.shield.fill")
} }
Text(viewModel.isLoading ? "Verifying..." : L10n.Auth.verifyEmailButton)
// Verify Button .font(.headline)
Button(action: {
viewModel.verifyEmail()
}) {
HStack {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "checkmark.shield.fill")
Text(L10n.Auth.verifyEmailButton)
.fontWeight(.semibold) .fontWeight(.semibold)
} }
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
viewModel.code.count == 6 && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(
color: viewModel.code.count == 6 && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
radius: 10,
y: 5
)
} }
.frame(maxWidth: .infinity) .disabled(viewModel.code.count != 6 || viewModel.isLoading)
.frame(height: 50)
.background( // Help Text
viewModel.code.count == 6 && !viewModel.isLoading Text(L10n.Auth.verifyHelpText)
? Color.appPrimary .font(.system(size: 12, weight: .medium))
: Color.gray.opacity(0.3) .foregroundColor(Color.appTextSecondary)
) .multilineTextAlignment(.center)
.foregroundColor(Color.appTextOnPrimary)
.cornerRadius(12)
} }
.disabled(viewModel.code.count != 6 || viewModel.isLoading) .padding(OrganicSpacing.cozy)
.padding(.horizontal) .background(OrganicVerifyEmailBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer().frame(height: 20) Spacer()
// Help Text
Text(L10n.Auth.verifyHelpText)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
} }
} }
} }
@@ -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)
} }
} }
} }