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 {
ZStack {
Color.appBackgroundPrimary.ignoresSafeArea()
WarmGradientBackground()
contentStateView
}
.onAppear {

View File

@@ -12,7 +12,6 @@ struct ContractorsListView: View {
@State private var showSpecialtyFilter = false
@State private var showingUpgradePrompt = false
// Lookups from DataManagerObservable
private var contractorSpecialties: [ContractorSpecialty] { dataManager.contractorSpecialties }
var specialties: [String] {
@@ -23,7 +22,6 @@ struct ContractorsListView: View {
viewModel.contractors
}
// Client-side filtering since backend doesn't support search/filter params
var filteredContractors: [ContractorSummary] {
contractors.filter { contractor in
let matchesSearch = searchText.isEmpty ||
@@ -36,59 +34,58 @@ struct ContractorsListView: View {
}
}
// Check if upgrade screen should be shown (disables add button)
private var shouldShowUpgrade: Bool {
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
}
var body: some View {
ZStack {
Color.appBackgroundPrimary.ignoresSafeArea()
WarmGradientBackground()
VStack(spacing: 0) {
// Search Bar
SearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder)
.padding(.horizontal, AppSpacing.md)
.padding(.top, AppSpacing.sm)
OrganicSearchBar(text: $searchText, placeholder: L10n.Contractors.searchPlaceholder)
.padding(.horizontal, 16)
.padding(.top, 8)
// Active Filters
if showFavoritesOnly || selectedSpecialty != nil {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: AppSpacing.xs) {
if showFavoritesOnly {
FilterChip(
title: L10n.Contractors.favorites,
icon: "star.fill",
onRemove: { showFavoritesOnly = false }
)
}
if let specialty = selectedSpecialty {
FilterChip(
title: specialty,
onRemove: { selectedSpecialty = nil }
)
}
// Active Filters
if showFavoritesOnly || selectedSpecialty != nil {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if showFavoritesOnly {
OrganicFilterChip(
title: L10n.Contractors.favorites,
icon: "star.fill",
onRemove: { showFavoritesOnly = false }
)
}
.padding(.horizontal, AppSpacing.md)
}
.padding(.vertical, AppSpacing.xs)
}
// Content - use filteredContractors for client-side filtering
if let specialty = selectedSpecialty {
OrganicFilterChip(
title: specialty,
onRemove: { selectedSpecialty = nil }
)
}
}
.padding(.horizontal, 16)
}
.padding(.vertical, 8)
}
// Content
ListAsyncContentView(
items: filteredContractors,
isLoading: viewModel.isLoading,
errorMessage: viewModel.errorMessage,
content: { contractorList in
ContractorsContent(
OrganicContractorsContent(
contractors: contractorList,
onToggleFavorite: toggleFavorite
)
},
emptyContent: {
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
EmptyContractorsView(
OrganicEmptyContractorsView(
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
)
} else {
@@ -107,82 +104,77 @@ struct ContractorsListView: View {
)
}
}
.navigationTitle(L10n.Contractors.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: AppSpacing.sm) {
// Favorites Filter (client-side, no API call needed)
Button(action: {
showFavoritesOnly.toggle()
}) {
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
}
// Specialty Filter (client-side, no API call needed)
Menu {
Button(action: {
selectedSpecialty = nil
}) {
Label(L10n.Contractors.allSpecialties, systemImage: selectedSpecialty == nil ? "checkmark" : "")
}
Divider()
ForEach(specialties, id: \.self) { specialty in
Button(action: {
selectedSpecialty = specialty
}) {
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
}
// Add Button (disabled when showing upgrade screen)
Button(action: {
let currentCount = viewModel.contractors.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
// Track paywall shown
PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount])
showingUpgradePrompt = true
} else {
showingAddSheet = true
}
}) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(Color.appPrimary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 12) {
// Favorites Filter
Button(action: {
showFavoritesOnly.toggle()
}) {
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
.font(.system(size: 16, weight: .medium))
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
}
// Specialty Filter
Menu {
Button(action: {
selectedSpecialty = nil
}) {
Label(L10n.Contractors.allSpecialties, systemImage: selectedSpecialty == nil ? "checkmark" : "")
}
Divider()
ForEach(specialties, id: \.self) { specialty in
Button(action: {
selectedSpecialty = specialty
}) {
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
.font(.system(size: 16, weight: .medium))
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
}
// Add Button
Button(action: {
let currentCount = viewModel.contractors.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
PostHogAnalytics.shared.capture(AnalyticsEvents.contractorPaywallShown, properties: ["current_count": currentCount])
showingUpgradePrompt = true
} else {
showingAddSheet = true
}
}) {
OrganicToolbarButton(systemName: "plus", isPrimary: true)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
}
}
.sheet(isPresented: $showingAddSheet) {
ContractorFormSheet(
contractor: nil,
onSave: {
loadContractors()
}
)
.presentationDetents([.large])
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt)
}
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown)
loadContractors()
}
// No need for onChange on searchText - filtering is client-side
// Contractor specialties are loaded from DataManagerObservable
}
.sheet(isPresented: $showingAddSheet) {
ContractorFormSheet(
contractor: nil,
onSave: {
loadContractors()
}
)
.presentationDetents([.large])
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt)
}
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.contractorScreenShown)
loadContractors()
}
}
private func loadContractors(forceRefresh: Bool = false) {
// Load all contractors, filtering is done client-side
viewModel.loadContractors(forceRefresh: forceRefresh)
}
@@ -195,73 +187,82 @@ struct ContractorsListView: View {
}
}
// MARK: - Search Bar
struct SearchBar: View {
// MARK: - Organic Search Bar
private struct OrganicSearchBar: View {
@Binding var text: String
var placeholder: String
var body: some View {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "magnifyingglass")
.foregroundColor(Color.appTextSecondary)
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: "magnifyingglass")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
}
TextField(placeholder, text: $text)
.font(.body)
.font(.system(size: 16, weight: .medium))
if !text.isEmpty {
Button(action: { text = "" }) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 18))
.foregroundColor(Color.appTextSecondary)
}
}
}
.padding(AppSpacing.sm)
.padding(14)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(.subtle)
}
}
// MARK: - Filter Chip
struct FilterChip: View {
// MARK: - Organic Filter Chip
private struct OrganicFilterChip: View {
let title: String
var icon: String? = nil
let onRemove: () -> Void
var body: some View {
HStack(spacing: AppSpacing.xxs) {
HStack(spacing: 6) {
if let icon = icon {
Image(systemName: icon)
.font(.caption)
.font(.system(size: 12, weight: .semibold))
}
Text(title)
.font(.footnote.weight(.medium))
.font(.system(size: 13, weight: .semibold))
Button(action: onRemove) {
Image(systemName: "xmark")
.font(.caption2)
.font(.system(size: 10, weight: .bold))
}
}
.padding(.horizontal, AppSpacing.sm)
.padding(.vertical, AppSpacing.xxs)
.background(Color.appPrimary.opacity(0.1))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.appPrimary.opacity(0.15))
.foregroundColor(Color.appPrimary)
.cornerRadius(AppRadius.full)
.clipShape(Capsule())
}
}
// MARK: - Contractors Content
// MARK: - Organic Contractors Content
private struct ContractorsContent: View {
private struct OrganicContractorsContent: View {
let contractors: [ContractorSummary]
let onToggleFavorite: (Int32) -> Void
var body: some View {
ScrollView {
LazyVStack(spacing: AppSpacing.sm) {
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 12) {
ForEach(contractors, id: \.id) { contractor in
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
ContractorCard(
OrganicContractorCard(
contractor: contractor,
onToggleFavorite: {
onToggleFavorite(contractor.id)
@@ -271,8 +272,8 @@ private struct ContractorsContent: View {
.buttonStyle(PlainButtonStyle())
}
}
.padding(AppSpacing.md)
.padding(.bottom, AppSpacing.xxxl)
.padding(16)
.padding(.bottom, 40)
}
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: 0)
@@ -280,32 +281,189 @@ private struct ContractorsContent: View {
}
}
// MARK: - Empty State
struct EmptyContractorsView: View {
let hasFilters: Bool
// MARK: - Organic Contractor Card
private struct OrganicContractorCard: View {
let contractor: ContractorSummary
let onToggleFavorite: () -> Void
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack(spacing: AppSpacing.md) {
Image(systemName: "person.badge.plus")
.font(.system(size: 64))
.foregroundColor(Color.appTextSecondary.opacity(0.7))
HStack(spacing: 14) {
// Avatar
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 50, height: 50)
Text(hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle)
.font(.title3.weight(.semibold))
.foregroundColor(Color.appTextSecondary)
if !hasFilters {
Text(L10n.Contractors.emptyNoFilters)
.font(.callout)
.foregroundColor(Color.appTextSecondary.opacity(0.7))
Text(String(contractor.name.prefix(1)).uppercased())
.font(.system(size: 20, weight: .bold))
.foregroundColor(Color.appTextOnPrimary)
}
VStack(alignment: .leading, spacing: 4) {
Text(contractor.name)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
.lineLimit(1)
if let company = contractor.company, !company.isEmpty {
Text(company)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.lineLimit(1)
}
if !contractor.specialties.isEmpty {
HStack(spacing: 4) {
ForEach(contractor.specialties.prefix(2), id: \.id) { specialty in
Text(specialty.name)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color.appPrimary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.appPrimary.opacity(0.1))
.clipShape(Capsule())
}
if contractor.specialties.count > 2 {
Text("+\(contractor.specialties.count - 2)")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
}
}
}
}
Spacer()
// Favorite Button
Button(action: onToggleFavorite) {
Image(systemName: contractor.isFavorite ? "star.fill" : "star")
.font(.system(size: 18, weight: .medium))
.foregroundColor(contractor.isFavorite ? Color.appAccent : Color.appTextSecondary)
}
.buttonStyle(.plain)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.appTextSecondary.opacity(0.5))
}
.padding(AppSpacing.xl)
.padding(16)
.background(
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: 1)
.fill(Color.appPrimary.opacity(colorScheme == .dark ? 0.04 : 0.02))
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.8)
.offset(x: geo.size.width * 0.6, y: 0)
.blur(radius: 10)
}
GrainTexture(opacity: 0.01)
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.naturalShadow(.medium)
}
}
struct ContractorsListView_Previews: PreviewProvider {
static var previews: some View {
// MARK: - Organic Toolbar Button
private struct OrganicToolbarButton: View {
let systemName: String
var isPrimary: Bool = false
var body: some View {
ZStack {
if isPrimary {
Circle()
.fill(Color.appPrimary)
.frame(width: 32, height: 32)
Image(systemName: systemName)
.font(.system(size: 14, weight: .bold))
.foregroundColor(Color.appTextOnPrimary)
} else {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: systemName)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
}
}
}
// MARK: - Organic Empty Contractors View
private struct OrganicEmptyContractorsView: View {
let hasFilters: Bool
@State private var isAnimating = false
var body: some View {
VStack(spacing: OrganicSpacing.comfortable) {
Spacer()
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true),
value: isAnimating
)
Image(systemName: "person.badge.plus")
.font(.system(size: 44, weight: .medium))
.foregroundColor(Color.appPrimary)
}
VStack(spacing: 8) {
Text(hasFilters ? L10n.Contractors.emptyFiltered : L10n.Contractors.emptyTitle)
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
if !hasFilters {
Text(L10n.Contractors.emptyNoFilters)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
}
Spacer()
}
.padding(24)
.onAppear {
isAnimating = true
}
}
}
#Preview {
NavigationView {
ContractorsListView()
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ struct MainTabView: View {
}
.id(refreshID)
.tabItem {
Label("Residences", image: "tab_view_house")
Label("Home", image: "tab_view_house")
}
.tag(0)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
@@ -24,7 +24,7 @@ struct MainTabView: View {
}
.id(refreshID)
.tabItem {
Label("Tasks", systemImage: "checkmark.circle.fill")
Label("Tasks", systemImage: "checklist")
}
.tag(1)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab)
@@ -34,7 +34,7 @@ struct MainTabView: View {
}
.id(refreshID)
.tabItem {
Label("Contractors", systemImage: "wrench.and.screwdriver.fill")
Label("Pros", systemImage: "wrench.and.screwdriver.fill")
}
.tag(2)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
@@ -44,7 +44,7 @@ struct MainTabView: View {
}
.id(refreshID)
.tabItem {
Label("Documents", systemImage: "doc.text.fill")
Label("Docs", systemImage: "doc.text.fill")
}
.tag(3)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
@@ -53,23 +53,44 @@ struct MainTabView: View {
.onChange(of: authManager.isAuthenticated) { _ in
selectedTab = 0
}
// Check for pending navigation when view appears (app launched from notification)
.onAppear {
// Configure tab bar appearance
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
// Use theme-aware colors
appearance.backgroundColor = UIColor(Color.appBackgroundSecondary)
// Selected item
appearance.stackedLayoutAppearance.selected.iconColor = UIColor(Color.appPrimary)
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [
.foregroundColor: UIColor(Color.appPrimary),
.font: UIFont.systemFont(ofSize: 10, weight: .semibold)
]
// Normal item
appearance.stackedLayoutAppearance.normal.iconColor = UIColor(Color.appTextSecondary)
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [
.foregroundColor: UIColor(Color.appTextSecondary),
.font: UIFont.systemFont(ofSize: 10, weight: .medium)
]
UITabBar.appearance().standardAppearance = appearance
UITabBar.appearance().scrollEdgeAppearance = appearance
// Handle pending navigation from push notification
if pushManager.pendingNavigationTaskId != nil {
selectedTab = 1 // Switch to Tasks tab
// Note: Don't clear here - AllTasksView will handle navigation and clear it
selectedTab = 1
}
}
// Handle push notification deep links - switch to appropriate tab
// The actual task navigation is handled by AllTasksView
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in
selectedTab = 1 // Switch to Tasks tab
selectedTab = 1
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in
selectedTab = 1 // Switch to Tasks tab
selectedTab = 1
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in
selectedTab = 0 // Switch to Residences tab
selectedTab = 0
}
}
}

View File

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

View File

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

View File

@@ -9,123 +9,215 @@ struct OnboardingJoinResidenceContent: View {
@State private var shareCode: String = ""
@State private var isLoading = false
@State private var errorMessage: String?
@State private var isAnimating = false
@FocusState private var isCodeFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme
private var isCodeValid: Bool {
shareCode.count == 6
}
var body: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
WarmGradientBackground()
// Content
VStack(spacing: AppSpacing.xl) {
// Icon
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 100, height: 100)
Image(systemName: "person.2.badge.key.fill")
.font(.system(size: 44))
.foregroundStyle(Color.appPrimary.gradient)
}
// Title
VStack(spacing: AppSpacing.sm) {
Text("Join a Residence")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
Text("Enter the 6-character code shared with you to join an existing home.")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
// Code input
VStack(alignment: .leading, spacing: AppSpacing.xs) {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "key.fill")
.foregroundColor(Color.appTextSecondary)
.frame(width: 20)
TextField("Enter share code", text: $shareCode)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.focused($isCodeFieldFocused)
.onChange(of: shareCode) { _, newValue in
// Limit to 6 characters
if newValue.count > 6 {
shareCode = String(newValue.prefix(6))
}
// Clear error when typing
errorMessage = nil
}
}
.padding(AppSpacing.md)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.md)
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
// Decorative blobs
GeometryReader { geo in
OrganicBlobShape(variation: 1)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.07),
Color.appPrimary.opacity(0.02),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.35
)
)
}
.padding(.horizontal, AppSpacing.xl)
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.35)
.offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.1)
.blur(radius: 25)
// Error message
if let error = errorMessage {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.callout)
.foregroundColor(Color.appError)
Spacer()
}
.padding(AppSpacing.md)
.background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md)
.padding(.horizontal, AppSpacing.xl)
}
// Loading indicator
if isLoading {
HStack {
ProgressView()
Text("Joining residence...")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
}
OrganicBlobShape(variation: 2)
.fill(
RadialGradient(
colors: [
Color.appAccent.opacity(0.05),
Color.appAccent.opacity(0.01),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.25
)
)
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.25)
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.6)
.blur(radius: 20)
}
Spacer()
VStack(spacing: 0) {
Spacer()
// Join button
Button(action: joinResidence) {
Text("Join Residence")
.font(.headline)
.fontWeight(.semibold)
// Content
VStack(spacing: OrganicSpacing.comfortable) {
// Icon with pulsing glow
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 70
)
)
.frame(width: 140, height: 140)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 90, height: 90)
Image(systemName: "person.2.badge.key.fill")
.font(.system(size: 40, weight: .medium))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
// Title
VStack(spacing: 10) {
Text("Join a Residence")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text("Enter the 6-character code shared with you to join an existing home.")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
.padding(.horizontal, 20)
}
// Code input card
VStack(spacing: 16) {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "key.fill")
.font(.system(size: 17, weight: .medium))
.foregroundColor(Color.appPrimary)
}
TextField("Enter share code", text: $shareCode)
.font(.system(size: 20, weight: .semibold, design: .monospaced))
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.focused($isCodeFieldFocused)
.onChange(of: shareCode) { _, newValue in
// Limit to 6 characters
if newValue.count > 6 {
shareCode = String(newValue.prefix(6))
}
// Clear error when typing
errorMessage = nil
}
}
.padding(18)
.background(
ZStack {
Color.appBackgroundSecondary
GrainTexture(opacity: 0.01)
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(isCodeFieldFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.2), lineWidth: 2)
)
.naturalShadow(.medium)
}
.padding(.horizontal, OrganicSpacing.comfortable)
// Error message
if let error = errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(14)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.padding(.horizontal, OrganicSpacing.comfortable)
}
// Loading indicator
if isLoading {
HStack(spacing: 10) {
ProgressView()
.tint(Color.appPrimary)
Text("Joining residence...")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
}
Spacer()
// Join button
Button(action: joinResidence) {
HStack(spacing: 10) {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(isLoading ? "Joining..." : "Join Residence")
.font(.system(size: 17, weight: .semibold))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
isCodeValid && !isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
)
.cornerRadius(AppRadius.md)
.shadow(color: isCodeValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(isCodeValid ? .medium : .subtle)
}
.disabled(!isCodeValid || isLoading)
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
}
.disabled(!isCodeValid || isLoading)
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
}
.background(Color.appBackgroundPrimary)
.onAppear {
isAnimating = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isCodeFieldFocused = true
}
@@ -161,24 +253,26 @@ struct OnboardingJoinResidenceView: View {
var onSkip: () -> Void
var body: some View {
VStack(spacing: 0) {
// Navigation bar
HStack {
Spacer()
ZStack {
WarmGradientBackground()
Button(action: onSkip) {
Text("Skip")
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appTextSecondary)
VStack(spacing: 0) {
// Navigation bar
HStack {
Spacer()
Button(action: onSkip) {
Text("Skip")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
}
.padding(.horizontal, AppSpacing.lg)
.padding(.vertical, AppSpacing.md)
.padding(.horizontal, 20)
.padding(.vertical, 12)
OnboardingJoinResidenceContent(onJoined: onJoined)
OnboardingJoinResidenceContent(onJoined: onJoined)
}
}
.background(Color.appBackgroundPrimary)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,104 +7,190 @@ struct OnboardingWelcomeView: View {
var onLogin: () -> Void
@State private var showingLoginSheet = false
@State private var isAnimating = false
@State private var iconScale: CGFloat = 0.8
@State private var iconOpacity: Double = 0
var body: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
WarmGradientBackground()
// Hero section
VStack(spacing: AppSpacing.xl) {
// App icon
Image("icon")
.resizable()
.scaledToFit()
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: AppRadius.xxl))
.shadow(color: Color.appPrimary.opacity(0.3), radius: 20, y: 10)
// Decorative blobs
GeometryReader { geo in
OrganicBlobShape(variation: 0)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.08),
Color.appPrimary.opacity(0.02),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.4
)
)
.frame(width: geo.size.width * 0.7, height: geo.size.height * 0.4)
.offset(x: -geo.size.width * 0.2, y: geo.size.height * 0.1)
.blur(radius: 30)
// Welcome text
VStack(spacing: AppSpacing.sm) {
Text("Welcome to Casera")
.font(.largeTitle)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
Text("Your home maintenance companion")
.font(.title3)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
OrganicBlobShape(variation: 1)
.fill(
RadialGradient(
colors: [
Color.appAccent.opacity(0.06),
Color.appAccent.opacity(0.01),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.3
)
)
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.3)
.offset(x: geo.size.width * 0.6, y: geo.size.height * 0.65)
.blur(radius: 25)
}
Spacer()
VStack(spacing: 0) {
Spacer()
// Action buttons
VStack(spacing: AppSpacing.md) {
// Primary CTA - Start Fresh
Button(action: onStartFresh) {
HStack(spacing: AppSpacing.sm) {
// Hero section
VStack(spacing: OrganicSpacing.comfortable) {
// Animated icon with glow
ZStack {
// Outer pulsing glow
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.2),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 100
)
)
.frame(width: 200, height: 200)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
// App icon
Image("icon")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text("Start Fresh")
.font(.headline)
.fontWeight(.semibold)
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.naturalShadow(.pronounced)
.scaleEffect(iconScale)
.opacity(iconOpacity)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
// Welcome text
VStack(spacing: 10) {
Text("Welcome to Casera")
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
Text("Your home maintenance companion")
.font(.system(size: 17, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
}
Spacer()
// Action buttons
VStack(spacing: 14) {
// Primary CTA - Start Fresh
Button(action: onStartFresh) {
HStack(spacing: 12) {
Image("icon")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text("Start Fresh")
.font(.system(size: 17, weight: .semibold))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
)
.cornerRadius(AppRadius.md)
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
// Secondary CTA - Join Existing
Button(action: onJoinExisting) {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "person.2.fill")
.font(.title3)
Text("I have a code to join")
.font(.headline)
.fontWeight(.medium)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(.medium)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appPrimary)
.background(Color.appPrimary.opacity(0.1))
.cornerRadius(AppRadius.md)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
// Returning user login
Button(action: {
showingLoginSheet = true
}) {
Text("Already have an account? Log in")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
// Secondary CTA - Join Existing
Button(action: onJoinExisting) {
HStack(spacing: 12) {
Image(systemName: "person.2.fill")
.font(.system(size: 18, weight: .medium))
Text("I have a code to join")
.font(.system(size: 17, weight: .medium))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appPrimary)
.background(Color.appPrimary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.appPrimary.opacity(0.2), lineWidth: 1)
)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
// Returning user login
Button(action: {
showingLoginSheet = true
}) {
Text("Already have an account? Log in")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
.padding(.top, 8)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
.padding(.top, AppSpacing.sm)
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
// Floating leaves decoration
HStack(spacing: 50) {
FloatingLeaf(delay: 0, size: 16, color: Color.appPrimary)
FloatingLeaf(delay: 0.5, size: 12, color: Color.appAccent)
FloatingLeaf(delay: 1.0, size: 18, color: Color.appPrimary)
}
.opacity(0.5)
.padding(.bottom, 20)
}
.padding(.horizontal, AppSpacing.xl)
.padding(.bottom, AppSpacing.xxxl)
}
.background(Color.appBackgroundPrimary)
.sheet(isPresented: $showingLoginSheet) {
LoginView(onLoginSuccess: {
showingLoginSheet = false
onLogin()
})
}
.onAppear {
isAnimating = true
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
iconScale = 1.0
iconOpacity = 1.0
}
}
}
}

View File

@@ -7,120 +7,213 @@ struct ForgotPasswordView: View {
var body: some View {
NavigationView {
Form {
// Header Section
Section {
VStack(spacing: 12) {
Image(systemName: "key.fill")
.font(.system(size: 60))
.foregroundStyle(Color.appPrimary.gradient)
.padding(.vertical)
ZStack {
WarmGradientBackground()
Text("Forgot Password?")
.font(.title2)
.fontWeight(.bold)
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.spacious) {
Spacer()
.frame(height: OrganicSpacing.comfortable)
Text("Enter your email address and we'll send you a verification code")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
// Email Input Section
Section {
TextField("Email Address", text: $viewModel.email)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.focused($isEmailFocused)
.submitLabel(.go)
.onSubmit {
viewModel.requestPasswordReset()
}
.onChange(of: viewModel.email) { _, _ in
viewModel.clearError()
}
} header: {
Text("Email")
} footer: {
Text("We'll send a 6-digit verification code to this address")
}
.listRowBackground(Color.appBackgroundSecondary)
// Error/Success Messages
if let errorMessage = viewModel.errorMessage {
Section {
Label {
Text(errorMessage)
.foregroundColor(Color.appError)
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
if let successMessage = viewModel.successMessage {
Section {
Label {
Text(successMessage)
.foregroundColor(Color.appAccent)
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appAccent)
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
// Send Code Button
Section {
Button(action: {
viewModel.requestPasswordReset()
}) {
HStack {
Spacer()
if viewModel.isLoading {
ProgressView()
} else {
Label("Send Reset Code", systemImage: "envelope.fill")
.fontWeight(.semibold)
Image(systemName: "key.fill")
.font(.system(size: 48, weight: .medium))
.foregroundColor(Color.appPrimary)
}
Spacer()
}
}
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
Button(action: {
dismiss()
}) {
HStack {
Spacer()
Text("Back to Login")
.foregroundColor(Color.appTextSecondary)
Spacer()
VStack(spacing: 8) {
Text("Forgot Password?")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text("Enter your email address and we'll send you a verification code")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
// Form Card
VStack(spacing: 20) {
// Email Field
VStack(alignment: .leading, spacing: 8) {
Text("EMAIL")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: "envelope.fill")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
}
TextField("Email Address", text: $viewModel.email)
.font(.system(size: 16, weight: .medium))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.focused($isEmailFocused)
.submitLabel(.go)
.onSubmit {
viewModel.requestPasswordReset()
}
.onChange(of: viewModel.email) { _, _ in
viewModel.clearError()
}
}
.padding(16)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isEmailFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
.animation(.easeInOut(duration: 0.2), value: isEmailFocused)
Text("We'll send a 6-digit verification code to this address")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Success Message
if let successMessage = viewModel.successMessage {
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appAccent)
Text(successMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appAccent)
Spacer()
}
.padding(16)
.background(Color.appAccent.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Send Code Button
Button(action: {
viewModel.requestPasswordReset()
}) {
HStack(spacing: 8) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "envelope.fill")
}
Text(viewModel.isLoading ? "Sending..." : "Send Reset Code")
.font(.headline)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
!viewModel.email.isEmpty && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(
color: !viewModel.email.isEmpty && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
radius: 10,
y: 5
)
}
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
// Back to Login
Button(action: { dismiss() }) {
Text("Back to Login")
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
}
.padding(.top, 8)
}
.padding(OrganicSpacing.cozy)
.background(OrganicFormCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("Reset Password")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
isEmailFocused = true
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.requestPasswordReset() }
)
}
}
}
// MARK: - Organic Form Card Background
private struct OrganicFormCardBackground: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: 0)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
Color.appPrimary.opacity(0.01)
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.5
)
)
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.5)
.offset(x: geo.size.width * 0.45, y: -geo.size.height * 0.1)
.blur(radius: 20)
}
GrainTexture(opacity: 0.015)
}
}
}

View File

@@ -12,244 +12,6 @@ struct ResetPasswordView: View {
case newPassword, confirmPassword
}
var body: some View {
NavigationView {
Form {
// Header Section
Section {
VStack(spacing: 12) {
Image(systemName: "lock.rotation")
.font(.system(size: 60))
.foregroundStyle(Color.appPrimary.gradient)
.padding(.vertical)
Text("Set New Password")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
Text("Create a strong password to secure your account")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
// Password Requirements
Section {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: viewModel.newPassword.count >= 8 ? "checkmark.circle.fill" : "circle")
.foregroundColor(viewModel.newPassword.count >= 8 ? Color.appPrimary : Color.appTextSecondary)
Text("At least 8 characters")
.font(.caption)
.foregroundColor(Color.appTextPrimary)
}
HStack(spacing: 8) {
Image(systemName: hasLetter ? "checkmark.circle.fill" : "circle")
.foregroundColor(hasLetter ? Color.appPrimary : Color.appTextSecondary)
Text("Contains letters")
.font(.caption)
.foregroundColor(Color.appTextPrimary)
}
HStack(spacing: 8) {
Image(systemName: hasNumber ? "checkmark.circle.fill" : "circle")
.foregroundColor(hasNumber ? Color.appPrimary : Color.appTextSecondary)
Text("Contains numbers")
.font(.caption)
.foregroundColor(Color.appTextPrimary)
}
HStack(spacing: 8) {
Image(systemName: passwordsMatch ? "checkmark.circle.fill" : "circle")
.foregroundColor(passwordsMatch ? Color.appPrimary : Color.appTextSecondary)
Text("Passwords match")
.font(.caption)
.foregroundColor(Color.appTextPrimary)
}
}
} header: {
Text("Password Requirements")
}
// New Password Input
Section {
HStack {
if isNewPasswordVisible {
TextField("Enter new password", text: $viewModel.newPassword)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .newPassword)
.submitLabel(.next)
.onSubmit {
focusedField = .confirmPassword
}
} else {
SecureField("Enter new password", text: $viewModel.newPassword)
.focused($focusedField, equals: .newPassword)
.submitLabel(.next)
.onSubmit {
focusedField = .confirmPassword
}
}
Button(action: {
isNewPasswordVisible.toggle()
}) {
Image(systemName: isNewPasswordVisible ? "eye.slash.fill" : "eye.fill")
.foregroundColor(Color.appTextSecondary)
}
.buttonStyle(.plain)
}
.onChange(of: viewModel.newPassword) { _, _ in
viewModel.clearError()
}
} header: {
Text("New Password")
}
// Confirm Password Input
Section {
HStack {
if isConfirmPasswordVisible {
TextField("Re-enter new password", text: $viewModel.confirmPassword)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .confirmPassword)
.submitLabel(.go)
.onSubmit {
viewModel.resetPassword()
}
} else {
SecureField("Re-enter new password", text: $viewModel.confirmPassword)
.focused($focusedField, equals: .confirmPassword)
.submitLabel(.go)
.onSubmit {
viewModel.resetPassword()
}
}
Button(action: {
isConfirmPasswordVisible.toggle()
}) {
Image(systemName: isConfirmPasswordVisible ? "eye.slash.fill" : "eye.fill")
.foregroundColor(Color.appTextSecondary)
}
.buttonStyle(.plain)
}
.onChange(of: viewModel.confirmPassword) { _, _ in
viewModel.clearError()
}
} header: {
Text("Confirm Password")
}
// Error/Success Messages
if let errorMessage = viewModel.errorMessage {
Section {
Label {
Text(errorMessage)
.foregroundColor(Color.appError)
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
}
}
}
if let successMessage = viewModel.successMessage {
Section {
Label {
Text(successMessage)
.foregroundColor(Color.appPrimary)
.multilineTextAlignment(.center)
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appPrimary)
}
}
}
// Reset Password Button
Section {
Button(action: {
viewModel.resetPassword()
}) {
HStack {
Spacer()
if viewModel.isLoading || viewModel.currentStep == .loggingIn {
ProgressView()
.padding(.trailing, 8)
Text(viewModel.currentStep == .loggingIn ? "Logging in..." : "Resetting...")
.fontWeight(.semibold)
} else {
Label("Reset Password", systemImage: "lock.shield.fill")
.fontWeight(.semibold)
}
Spacer()
}
}
.disabled(!isFormValid || viewModel.isLoading || viewModel.currentStep == .loggingIn)
// Return to Login Button (shown only if auto-login fails)
if viewModel.currentStep == .success {
Button(action: {
viewModel.reset()
onSuccess()
}) {
HStack {
Spacer()
Text("Return to Login")
.fontWeight(.semibold)
Spacer()
}
}
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("Reset Password")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
// Only show back button if not from deep link and not logging in
if (viewModel.resetToken == nil || viewModel.currentStep != .resetPassword) && viewModel.currentStep != .loggingIn {
Button(action: {
if viewModel.currentStep == .success {
viewModel.reset()
onSuccess()
} else {
viewModel.moveToPreviousStep()
}
}) {
HStack(spacing: 4) {
Image(systemName: viewModel.currentStep == .success ? "xmark" : "chevron.left")
.font(.system(size: 16))
Text(viewModel.currentStep == .success ? "Close" : "Back")
.font(.subheadline)
}
}
}
}
}
.onAppear {
focusedField = .newPassword
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.resetPassword() }
)
}
}
// Computed Properties
private var hasLetter: Bool {
viewModel.newPassword.range(of: "[A-Za-z]", options: .regularExpression) != nil
@@ -271,6 +33,348 @@ struct ResetPasswordView: View {
hasNumber &&
passwordsMatch
}
var body: some View {
NavigationView {
ZStack {
WarmGradientBackground()
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.spacious) {
Spacer()
.frame(height: OrganicSpacing.comfortable)
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Image(systemName: "lock.rotation")
.font(.system(size: 48, weight: .medium))
.foregroundColor(Color.appPrimary)
}
VStack(spacing: 8) {
Text("Set New Password")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text("Create a strong password to secure your account")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
// Form Card
VStack(spacing: 20) {
// Password Requirements
VStack(alignment: .leading, spacing: 10) {
Text("PASSWORD REQUIREMENTS")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
VStack(alignment: .leading, spacing: 8) {
RequirementRow(
isMet: viewModel.newPassword.count >= 8,
text: "At least 8 characters"
)
RequirementRow(
isMet: hasLetter,
text: "Contains letters"
)
RequirementRow(
isMet: hasNumber,
text: "Contains numbers"
)
RequirementRow(
isMet: passwordsMatch,
text: "Passwords match"
)
}
.padding(16)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// New Password Field
VStack(alignment: .leading, spacing: 8) {
Text("NEW PASSWORD")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: "lock.fill")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
}
Group {
if isNewPasswordVisible {
TextField("Enter new password", text: $viewModel.newPassword)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} else {
SecureField("Enter new password", text: $viewModel.newPassword)
}
}
.font(.system(size: 16, weight: .medium))
.focused($focusedField, equals: .newPassword)
.submitLabel(.next)
.onSubmit { focusedField = .confirmPassword }
.onChange(of: viewModel.newPassword) { _, _ in
viewModel.clearError()
}
Button(action: { isNewPasswordVisible.toggle() }) {
Image(systemName: isNewPasswordVisible ? "eye.slash.fill" : "eye.fill")
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
.padding(16)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(focusedField == .newPassword ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
}
// Confirm Password Field
VStack(alignment: .leading, spacing: 8) {
Text("CONFIRM PASSWORD")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: "lock.fill")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
}
Group {
if isConfirmPasswordVisible {
TextField("Re-enter new password", text: $viewModel.confirmPassword)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} else {
SecureField("Re-enter new password", text: $viewModel.confirmPassword)
}
}
.font(.system(size: 16, weight: .medium))
.focused($focusedField, equals: .confirmPassword)
.submitLabel(.go)
.onSubmit { viewModel.resetPassword() }
.onChange(of: viewModel.confirmPassword) { _, _ in
viewModel.clearError()
}
Button(action: { isConfirmPasswordVisible.toggle() }) {
Image(systemName: isConfirmPasswordVisible ? "eye.slash.fill" : "eye.fill")
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
.padding(16)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(focusedField == .confirmPassword ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Success Message
if let successMessage = viewModel.successMessage {
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appPrimary)
Text(successMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
Spacer()
}
.padding(16)
.background(Color.appPrimary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Reset Password Button
Button(action: {
viewModel.resetPassword()
}) {
HStack(spacing: 8) {
if viewModel.isLoading || viewModel.currentStep == .loggingIn {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "lock.shield.fill")
}
Text(viewModel.currentStep == .loggingIn ? "Logging in..." : (viewModel.isLoading ? "Resetting..." : "Reset Password"))
.font(.headline)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
isFormValid && !viewModel.isLoading && viewModel.currentStep != .loggingIn
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(
color: isFormValid && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
radius: 10,
y: 5
)
}
.disabled(!isFormValid || viewModel.isLoading || viewModel.currentStep == .loggingIn)
// Return to Login Button
if viewModel.currentStep == .success {
Button(action: {
viewModel.reset()
onSuccess()
}) {
Text("Return to Login")
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundColor(Color.appPrimary)
}
.padding(.top, 8)
}
}
.padding(OrganicSpacing.cozy)
.background(OrganicResetCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
}
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
if (viewModel.resetToken == nil || viewModel.currentStep != .resetPassword) && viewModel.currentStep != .loggingIn {
Button(action: {
if viewModel.currentStep == .success {
viewModel.reset()
onSuccess()
} else {
viewModel.moveToPreviousStep()
}
}) {
HStack(spacing: 6) {
Image(systemName: viewModel.currentStep == .success ? "xmark" : "chevron.left")
.font(.system(size: 14, weight: .semibold))
Text(viewModel.currentStep == .success ? "Close" : "Back")
.font(.system(size: 15, weight: .medium))
}
.foregroundColor(Color.appPrimary)
}
}
}
}
.onAppear {
focusedField = .newPassword
}
}
}
}
// MARK: - Requirement Row
private struct RequirementRow: View {
let isMet: Bool
let text: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: isMet ? "checkmark.circle.fill" : "circle")
.font(.system(size: 14, weight: .medium))
.foregroundColor(isMet ? Color.appPrimary : Color.appTextSecondary)
Text(text)
.font(.system(size: 13, weight: .medium))
.foregroundColor(isMet ? Color.appTextPrimary : Color.appTextSecondary)
}
}
}
// MARK: - Background
private struct OrganicResetCardBackground: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: 2)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
Color.appPrimary.opacity(0.01)
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.5
)
)
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.4)
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.55)
.blur(radius: 20)
}
GrainTexture(opacity: 0.015)
}
}
}
#Preview {

View File

@@ -7,149 +7,205 @@ struct VerifyResetCodeView: View {
var body: some View {
NavigationView {
Form {
// Header Section
Section {
VStack(spacing: 12) {
Image(systemName: "envelope.badge.fill")
.font(.system(size: 60))
.foregroundStyle(Color.appPrimary.gradient)
.padding(.vertical)
ZStack {
WarmGradientBackground()
Text("Check Your Email")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary)
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.spacious) {
Spacer()
.frame(height: OrganicSpacing.comfortable)
Text("We sent a 6-digit code to")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Text(viewModel.email)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
// Info Section
Section {
Label {
Text("Code expires in 15 minutes")
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
} icon: {
Image(systemName: "clock.fill")
.foregroundColor(Color.appAccent)
}
}
.listRowBackground(Color.appBackgroundSecondary)
// Code Input Section
Section {
TextField("000000", text: $viewModel.code)
.font(.system(size: 32, weight: .semibold, design: .rounded))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.focused($isCodeFocused)
.keyboardDismissToolbar()
.onChange(of: viewModel.code) { _, newValue in
// Limit to 6 digits
if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6))
Image(systemName: "envelope.badge.fill")
.font(.system(size: 48, weight: .medium))
.foregroundColor(Color.appPrimary)
}
// Only allow numbers
viewModel.code = newValue.filter { $0.isNumber }
viewModel.clearError()
}
} header: {
Text("Verification Code")
} footer: {
Text("Enter the 6-digit code from your email")
}
.listRowBackground(Color.appBackgroundSecondary)
// Error/Success Messages
if let errorMessage = viewModel.errorMessage {
Section {
Label {
Text(errorMessage)
.foregroundColor(Color.appError)
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
VStack(spacing: 8) {
Text("Check Your Email")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
if let successMessage = viewModel.successMessage {
Section {
Label {
Text(successMessage)
.foregroundColor(Color.appPrimary)
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appPrimary)
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
Text("We sent a 6-digit code to")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
// Verify Button
Section {
Button(action: {
viewModel.verifyResetCode()
}) {
HStack {
Spacer()
if viewModel.isLoading {
ProgressView()
} else {
Label("Verify Code", systemImage: "checkmark.shield.fill")
.fontWeight(.semibold)
Text(viewModel.email)
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary)
}
Spacer()
}
}
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
}
.listRowBackground(Color.appBackgroundSecondary)
// Help Section
Section {
VStack(spacing: 12) {
Text("Didn't receive the code?")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
Button(action: {
// Clear code and go back to request new one
viewModel.code = ""
viewModel.clearError()
viewModel.currentStep = .requestCode
}) {
Text("Send New Code")
.font(.subheadline)
.fontWeight(.semibold)
}
Text("Check your spam folder if you don't see it")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
// Form Card
VStack(spacing: 20) {
// Timer Info
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appAccent.opacity(0.1))
.frame(width: 40, height: 40)
Image(systemName: "clock.fill")
.font(.system(size: 18, weight: .medium))
.foregroundColor(Color.appAccent)
}
Text("Code expires in 15 minutes")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
Spacer()
}
.padding(16)
.background(Color.appAccent.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
// Code Input
VStack(alignment: .leading, spacing: 8) {
Text("VERIFICATION CODE")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
TextField("000000", text: $viewModel.code)
.font(.system(size: 32, weight: .bold, design: .rounded))
.multilineTextAlignment(.center)
.keyboardType(.numberPad)
.focused($isCodeFocused)
.keyboardDismissToolbar()
.padding(20)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
.onChange(of: viewModel.code) { _, newValue in
if newValue.count > 6 {
viewModel.code = String(newValue.prefix(6))
}
viewModel.code = newValue.filter { $0.isNumber }
viewModel.clearError()
}
Text("Enter the 6-digit code from your email")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Success Message
if let successMessage = viewModel.successMessage {
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(Color.appPrimary)
Text(successMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
Spacer()
}
.padding(16)
.background(Color.appPrimary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Verify Button
Button(action: {
viewModel.verifyResetCode()
}) {
HStack(spacing: 8) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "checkmark.shield.fill")
}
Text(viewModel.isLoading ? "Verifying..." : "Verify Code")
.font(.headline)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
viewModel.code.count == 6 && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(
color: viewModel.code.count == 6 && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
radius: 10,
y: 5
)
}
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
OrganicDivider()
.padding(.vertical, 4)
// Help Section
VStack(spacing: 12) {
Text("Didn't receive the code?")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
Button(action: {
viewModel.code = ""
viewModel.clearError()
viewModel.currentStep = .requestCode
}) {
Text("Send New Code")
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary)
}
Text("Check your spam folder if you don't see it")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
}
.padding(OrganicSpacing.cozy)
.background(OrganicVerifyCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
}
.frame(maxWidth: .infinity)
}
.listRowBackground(Color.clear)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("Verify Code")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
@@ -157,22 +213,51 @@ struct VerifyResetCodeView: View {
Button(action: {
viewModel.moveToPreviousStep()
}) {
HStack(spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "chevron.left")
.font(.system(size: 16))
.font(.system(size: 14, weight: .semibold))
Text("Back")
.font(.subheadline)
.font(.system(size: 15, weight: .medium))
}
.foregroundColor(Color.appPrimary)
}
}
}
.onAppear {
isCodeFocused = true
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.verifyResetCode() }
)
}
}
}
// MARK: - Background
private struct OrganicVerifyCardBackground: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: 1)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
Color.appPrimary.opacity(0.01)
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.5
)
)
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.4)
.offset(x: geo.size.width * 0.45, y: geo.size.height * 0.5)
.blur(radius: 20)
}
GrainTexture(opacity: 0.015)
}
}
}

View File

@@ -6,128 +6,213 @@ struct RegisterView: View {
@Environment(\.dismiss) var dismiss
@FocusState private var focusedField: Field?
@State private var showVerifyEmail = false
@State private var isPasswordVisible = false
@State private var isConfirmPasswordVisible = false
enum Field {
case username, email, password, confirmPassword
}
private var isFormValid: Bool {
!viewModel.username.isEmpty &&
!viewModel.email.isEmpty &&
!viewModel.password.isEmpty &&
!viewModel.confirmPassword.isEmpty
}
var body: some View {
NavigationView {
Form {
Section {
VStack(spacing: 16) {
Image(systemName: "person.badge.plus")
.font(.system(size: 60))
.foregroundStyle(Color.appPrimary.gradient)
ZStack {
WarmGradientBackground()
Text(L10n.Auth.joinCasera)
.font(.largeTitle)
.fontWeight(.bold)
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.spacious) {
Spacer()
.frame(height: OrganicSpacing.comfortable)
Text(L10n.Auth.startManaging)
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Section {
TextField(L10n.Auth.registerUsername, text: $viewModel.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textContentType(.username)
.focused($focusedField, equals: .username)
.submitLabel(.next)
.onSubmit {
focusedField = .email
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField)
TextField(L10n.Auth.registerEmail, text: $viewModel.email)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.focused($focusedField, equals: .email)
.submitLabel(.next)
.onSubmit {
focusedField = .password
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField)
} header: {
Text(L10n.Auth.accountInfo)
}
.listRowBackground(Color.appBackgroundSecondary)
Section {
// Using .newPassword enables iOS Strong Password generation
// iOS will automatically offer to save to iCloud Keychain after successful registration
SecureField(L10n.Auth.registerPassword, text: $viewModel.password)
.textContentType(.newPassword)
.focused($focusedField, equals: .password)
.submitLabel(.next)
.onSubmit {
focusedField = .confirmPassword
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField)
SecureField(L10n.Auth.registerConfirmPassword, text: $viewModel.confirmPassword)
.textContentType(.newPassword)
.focused($focusedField, equals: .confirmPassword)
.submitLabel(.go)
.onSubmit {
viewModel.register()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField)
} header: {
Text(L10n.Auth.security)
} footer: {
Text(L10n.Auth.passwordSuggestion)
}
.listRowBackground(Color.appBackgroundSecondary)
if let errorMessage = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.foregroundColor(Color.appError)
.font(.subheadline)
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
Section {
Button(action: viewModel.register) {
HStack {
Spacer()
if viewModel.isLoading {
ProgressView()
} else {
Text(L10n.Auth.registerButton)
.fontWeight(.semibold)
Image(systemName: "person.badge.plus")
.font(.system(size: 48, weight: .medium))
.foregroundColor(Color.appPrimary)
}
VStack(spacing: 8) {
Text(L10n.Auth.joinCasera)
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text(L10n.Auth.startManaging)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
Spacer()
}
// Registration Card
VStack(spacing: 20) {
// Username Field
OrganicTextField(
label: L10n.Auth.accountInfo,
placeholder: L10n.Auth.registerUsername,
text: $viewModel.username,
icon: "person.fill",
isFocused: focusedField == .username
)
.focused($focusedField, equals: .username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textContentType(.username)
.submitLabel(.next)
.onSubmit { focusedField = .email }
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField)
// Email Field
OrganicTextField(
label: nil,
placeholder: L10n.Auth.registerEmail,
text: $viewModel.email,
icon: "envelope.fill",
isFocused: focusedField == .email
)
.focused($focusedField, equals: .email)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.submitLabel(.next)
.onSubmit { focusedField = .password }
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField)
OrganicDivider()
.padding(.vertical, 4)
// Password Field
OrganicSecureField(
label: L10n.Auth.security,
placeholder: L10n.Auth.registerPassword,
text: $viewModel.password,
isVisible: $isPasswordVisible,
isFocused: focusedField == .password
)
.focused($focusedField, equals: .password)
.textContentType(.newPassword)
.submitLabel(.next)
.onSubmit { focusedField = .confirmPassword }
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField)
// Confirm Password Field
OrganicSecureField(
label: nil,
placeholder: L10n.Auth.registerConfirmPassword,
text: $viewModel.confirmPassword,
isVisible: $isConfirmPasswordVisible,
isFocused: focusedField == .confirmPassword
)
.focused($focusedField, equals: .confirmPassword)
.textContentType(.newPassword)
.submitLabel(.go)
.onSubmit { viewModel.register() }
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField)
Text(L10n.Auth.passwordSuggestion)
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(errorMessage)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Register Button
Button(action: viewModel.register) {
HStack(spacing: 8) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isLoading ? L10n.Auth.creatingAccount : L10n.Auth.registerButton)
.font(.headline)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
isFormValid && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(
color: isFormValid && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
radius: 10,
y: 5
)
}
.disabled(!isFormValid || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton)
// Login Link
HStack(spacing: 6) {
Text(L10n.Auth.alreadyHaveAccount)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
Button(L10n.Auth.signIn) {
dismiss()
}
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary)
}
.padding(.top, 8)
}
.padding(OrganicSpacing.cozy)
.background(OrganicFormBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
}
.disabled(viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton)
}
.listRowBackground(Color.appBackgroundSecondary)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Auth.registerTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(L10n.Common.cancel) {
dismiss()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
.padding(8)
.background(Color.appBackgroundSecondary.opacity(0.8))
.clipShape(Circle())
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerCancelButton)
}
@@ -135,23 +220,16 @@ struct RegisterView: View {
.fullScreenCover(isPresented: $viewModel.isRegistered) {
VerifyEmailView(
onVerifySuccess: {
// User has verified their email - mark as verified
// This will update RootView to show the main app
AuthenticationManager.shared.markVerified()
showVerifyEmail = false
dismiss()
},
onLogout: {
// Logout and return to login screen
AuthenticationManager.shared.logout()
dismiss()
}
)
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { viewModel.register() }
)
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.registrationScreenShown)
}
@@ -159,6 +237,136 @@ struct RegisterView: View {
}
}
// MARK: - Organic Text Field
private struct OrganicTextField: View {
let label: String?
let placeholder: String
@Binding var text: String
let icon: String
var isFocused: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let label = label {
Text(label.uppercased())
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
}
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: icon)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
}
TextField(placeholder, text: $text)
.font(.system(size: 16, weight: .medium))
}
.padding(16)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
.animation(.easeInOut(duration: 0.2), value: isFocused)
}
}
}
// MARK: - Organic Secure Field
private struct OrganicSecureField: View {
let label: String?
let placeholder: String
@Binding var text: String
@Binding var isVisible: Bool
var isFocused: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let label = label {
Text(label.uppercased())
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
}
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: "lock.fill")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appPrimary)
}
Group {
if isVisible {
TextField(placeholder, text: $text)
} else {
SecureField(placeholder, text: $text)
}
}
.font(.system(size: 16, weight: .medium))
Button(action: { isVisible.toggle() }) {
Image(systemName: isVisible ? "eye.slash.fill" : "eye.fill")
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
.padding(16)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
.animation(.easeInOut(duration: 0.2), value: isFocused)
}
}
}
// MARK: - Organic Form Background
private struct OrganicFormBackground: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: 1)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
Color.appPrimary.opacity(0.01)
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.5
)
)
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.4)
.offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.05)
.blur(radius: 20)
}
GrainTexture(opacity: 0.015)
}
}
}
#Preview {
RegisterView()
}

View File

@@ -7,70 +7,170 @@ struct JoinResidenceView: View {
let onJoined: () -> Void
@State private var shareCode: String = ""
@FocusState private var isCodeFocused: Bool
var body: some View {
NavigationView {
Form {
Section {
TextField(L10n.Residences.shareCode, text: $shareCode)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.onChange(of: shareCode) { newValue in
// Limit to 6 characters and uppercase
if newValue.count > 6 {
shareCode = String(newValue.prefix(6))
}
shareCode = shareCode.uppercased()
viewModel.clearError()
}
.disabled(viewModel.isLoading)
} header: {
Text(L10n.Residences.enterShareCode)
} footer: {
Text(L10n.Residences.shareCodeFooter)
.foregroundColor(Color.appTextSecondary)
}
.listRowBackground(Color.appBackgroundSecondary)
ZStack {
WarmGradientBackground()
if let error = viewModel.errorMessage {
Section {
Text(error)
.foregroundColor(Color.appError)
}
.listRowBackground(Color.appBackgroundSecondary)
}
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.spacious) {
Spacer()
.frame(height: OrganicSpacing.comfortable)
Section {
Button(action: joinResidence) {
HStack {
Spacer()
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
} else {
Text(L10n.Residences.joinButton)
.fontWeight(.semibold)
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 60
)
)
.frame(width: 120, height: 120)
Image(systemName: "person.badge.plus")
.font(.system(size: 48, weight: .medium))
.foregroundColor(Color.appPrimary)
}
VStack(spacing: 8) {
Text(L10n.Residences.joinTitle)
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text(L10n.Residences.enterShareCode)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
Spacer()
}
// Form Card
VStack(spacing: 20) {
// Share Code Input
VStack(alignment: .leading, spacing: 8) {
Text(L10n.Residences.shareCode.uppercased())
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
TextField("ABC123", text: $shareCode)
.font(.system(size: 32, weight: .bold, design: .rounded))
.multilineTextAlignment(.center)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.focused($isCodeFocused)
.disabled(viewModel.isLoading)
.padding(20)
.background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
)
.onChange(of: shareCode) { newValue in
if newValue.count > 6 {
shareCode = String(newValue.prefix(6))
}
shareCode = shareCode.uppercased()
viewModel.clearError()
}
Text(L10n.Residences.shareCodeFooter)
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
// Error Message
if let error = viewModel.errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
// Join Button
Button(action: joinResidence) {
HStack(spacing: 8) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "person.badge.plus")
}
Text(viewModel.isLoading ? "Joining..." : L10n.Residences.joinButton)
.font(.headline)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(
shareCode.count == 6 && !viewModel.isLoading
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
: AnyShapeStyle(Color.appTextSecondary)
)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.shadow(
color: shareCode.count == 6 && !viewModel.isLoading ? Color.appPrimary.opacity(0.3) : .clear,
radius: 10,
y: 5
)
}
.disabled(shareCode.count != 6 || viewModel.isLoading)
// Cancel Button
Button(action: { dismiss() }) {
Text(L10n.Common.cancel)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
}
.disabled(viewModel.isLoading)
.padding(.top, 8)
}
.padding(OrganicSpacing.cozy)
.background(OrganicJoinCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.naturalShadow(.pronounced)
.padding(.horizontal, 16)
Spacer()
}
.disabled(shareCode.count != 6 || viewModel.isLoading)
}
.listRowBackground(Color.appBackgroundSecondary)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Residences.joinTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(L10n.Common.cancel) {
dismiss()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
.padding(8)
.background(Color.appBackgroundSecondary.opacity(0.8))
.clipShape(Circle())
}
.disabled(viewModel.isLoading)
}
}
.onAppear {
isCodeFocused = true
}
}
}
@@ -85,7 +185,38 @@ struct JoinResidenceView: View {
onJoined()
dismiss()
}
// Error is handled by ViewModel and displayed via viewModel.errorMessage
}
}
}
// MARK: - Background
private struct OrganicJoinCardBackground: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: 1)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
Color.appPrimary.opacity(0.01)
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.5
)
)
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.5)
.offset(x: geo.size.width * 0.4, y: geo.size.height * 0.4)
.blur(radius: 20)
}
GrainTexture(opacity: 0.015)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,30 +21,35 @@ struct PromoContentView: View {
case .title(let text):
Text(text)
.font(.title3.bold())
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary)
.multilineTextAlignment(.center)
case .body(let text):
Text(text)
.font(.subheadline)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
case .checkItem(let text):
HStack(alignment: .top, spacing: 8) {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundColor(Color.appPrimary)
HStack(alignment: .top, spacing: 10) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 24, height: 24)
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(Color.appPrimary)
}
Text(text)
.font(.subheadline)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextPrimary)
Spacer()
}
case .italic(let text):
Text(text)
.font(.caption)
.font(.system(size: 12, weight: .medium))
.italic()
.foregroundColor(Color.appAccent)
.multilineTextAlignment(.center)
@@ -78,15 +83,12 @@ struct PromoContentView: View {
let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces)
result.append(.checkItem(text))
} else if trimmed.contains("<b>") && trimmed.contains("</b>") {
// Title line with emoji
let cleaned = trimmed
.replacingOccurrences(of: "<b>", with: "")
.replacingOccurrences(of: "</b>", with: "")
// Check if starts with emoji
if let firstScalar = cleaned.unicodeScalars.first,
firstScalar.properties.isEmoji && !firstScalar.properties.isASCIIHexDigit {
// Split emoji and title
let parts = cleaned.split(separator: " ", maxSplits: 1)
if parts.count == 2 {
result.append(.emoji(String(parts[0])))
@@ -104,7 +106,6 @@ struct PromoContentView: View {
result.append(.italic(text))
} else if trimmed.first?.unicodeScalars.first?.properties.isEmoji == true &&
trimmed.count <= 2 {
// Standalone emoji
result.append(.emoji(trimmed))
} else {
result.append(.body(trimmed))
@@ -126,6 +127,7 @@ struct UpgradePromptView: View {
@State private var selectedProduct: Product?
@State private var errorMessage: String?
@State private var showSuccessAlert = false
@State private var isAnimating = false
var triggerData: UpgradeTriggerData? {
subscriptionCache.upgradeTriggers[triggerKey]
@@ -133,133 +135,171 @@ struct UpgradePromptView: View {
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: AppSpacing.xl) {
// Icon
Image(systemName: "star.circle.fill")
.font(.system(size: 60))
.foregroundStyle(Color.appAccent.gradient)
.padding(.top, AppSpacing.xl)
ZStack {
WarmGradientBackground()
// Title
Text(triggerData?.title ?? "Upgrade to Pro")
.font(.title2.weight(.bold))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal)
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
// Hero Section
VStack(spacing: OrganicSpacing.comfortable) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appAccent.opacity(0.2),
Color.appAccent.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 80
)
)
.frame(width: 160, height: 160)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
value: isAnimating
)
// Message
Text(triggerData?.message ?? "Unlock unlimited access to all features")
.font(.body)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appAccent, Color.appAccent.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
// Pro Features Preview - Dynamic content or fallback
Group {
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
PromoContentView(content: promoContent)
.padding()
} else {
// Fallback to static features if no promo content
VStack(alignment: .leading, spacing: AppSpacing.md) {
FeatureRow(icon: "house.fill", text: "Unlimited properties")
FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
FeatureRow(icon: "person.2.fill", text: "Contractor management")
FeatureRow(icon: "doc.fill", text: "Document & warranty storage")
Image(systemName: "star.fill")
.font(.system(size: 36, weight: .medium))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
.padding()
}
}
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
.padding(.horizontal)
.padding(.top, OrganicSpacing.comfortable)
// Subscription Products
if storeKit.isLoading {
ProgressView()
.tint(Color.appPrimary)
.padding()
} else if !storeKit.products.isEmpty {
VStack(spacing: AppSpacing.md) {
ForEach(storeKit.products, id: \.id) { product in
SubscriptionProductButton(
product: product,
isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
}
)
VStack(spacing: 8) {
Text(triggerData?.title ?? "Upgrade to Pro")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
Text(triggerData?.message ?? "Unlock unlimited access to all features")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
.padding(.horizontal)
} else {
// Fallback upgrade button if products fail to load
Button(action: {
Task { await storeKit.loadProducts() }
}) {
HStack {
if isProcessing {
ProgressView()
.tint(Color.appTextOnPrimary)
} else {
Text("Retry Loading Products")
.fontWeight(.semibold)
// Features Card
VStack(spacing: 16) {
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
PromoContentView(content: promoContent)
} else {
VStack(alignment: .leading, spacing: 14) {
OrganicFeatureRow(icon: "house.fill", text: "Unlimited properties")
OrganicFeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
OrganicFeatureRow(icon: "person.2.fill", text: "Contractor management")
OrganicFeatureRow(icon: "doc.fill", text: "Document & warranty storage")
}
}
.frame(maxWidth: .infinity)
.foregroundColor(Color.appTextOnPrimary)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppRadius.md)
}
.disabled(isProcessing)
.padding(.horizontal)
}
.padding(OrganicSpacing.cozy)
.background(OrganicCardBackground())
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.naturalShadow(.medium)
.padding(.horizontal, 16)
// Error Message
if let error = errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.subheadline)
.foregroundColor(Color.appError)
// Subscription Products
VStack(spacing: 12) {
if storeKit.isLoading {
ProgressView()
.tint(Color.appPrimary)
.padding()
} else if !storeKit.products.isEmpty {
ForEach(storeKit.products, id: \.id) { product in
OrganicSubscriptionButton(
product: product,
isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
}
)
}
} else {
Button(action: {
Task { await storeKit.loadProducts() }
}) {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise")
Text("Retry Loading Products")
.font(.system(size: 16, weight: .semibold))
}
.frame(maxWidth: .infinity)
.frame(height: 56)
.foregroundColor(Color.appTextOnPrimary)
.background(Color.appPrimary)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
}
.padding()
.background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md)
.padding(.horizontal)
}
.padding(.horizontal, 16)
// Compare Plans
Button(action: {
showFeatureComparison = true
}) {
Text("Compare Free vs Pro")
.font(.subheadline)
.foregroundColor(Color.appPrimary)
}
// Error Message
if let error = errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError)
Spacer()
}
.padding(16)
.background(Color.appError.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal, 16)
}
// Restore Purchases
Button(action: {
handleRestore()
}) {
Text("Restore Purchases")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
// Links
VStack(spacing: 12) {
Button(action: {
showFeatureComparison = true
}) {
Text("Compare Free vs Pro")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
Button(action: {
handleRestore()
}) {
Text("Restore Purchases")
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
.padding(.bottom, OrganicSpacing.airy)
}
.padding(.bottom, AppSpacing.xl)
}
}
.background(Color.appBackgroundPrimary)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
isPresented = false
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
.padding(8)
.background(Color.appBackgroundSecondary.opacity(0.8))
.clipShape(Circle())
}
}
}
@@ -274,10 +314,12 @@ struct UpgradePromptView: View {
Text("You now have full access to all Pro features!")
}
.task {
// Refresh subscription cache to get latest upgrade triggers
subscriptionCache.refreshFromCache()
await storeKit.loadProducts()
}
.onAppear {
isAnimating = true
}
}
}
@@ -293,7 +335,6 @@ struct UpgradePromptView: View {
isProcessing = false
if transaction != nil {
// Purchase successful
showSuccessAlert = true
}
}
@@ -326,6 +367,144 @@ struct UpgradePromptView: View {
}
}
// MARK: - Organic Feature Row
private struct OrganicFeatureRow: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 36, height: 36)
Image(systemName: icon)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
Text(text)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextPrimary)
Spacer()
}
}
}
// MARK: - Organic Subscription Button
private struct OrganicSubscriptionButton: View {
let product: Product
let isSelected: Bool
let isProcessing: Bool
let onSelect: () -> Void
@Environment(\.colorScheme) var colorScheme
var isAnnual: Bool {
product.id.contains("annual")
}
var savingsText: String? {
if isAnnual {
return "Save 17%"
}
return nil
}
var body: some View {
Button(action: onSelect) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(product.displayName)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(isAnnual ? Color.appTextOnPrimary : Color.appTextPrimary)
if let savings = savingsText {
Text(savings)
.font(.system(size: 12, weight: .bold))
.foregroundColor(isAnnual ? Color.white.opacity(0.9) : Color.appPrimary)
}
}
Spacer()
if isProcessing && isSelected {
ProgressView()
.tint(isAnnual ? .white : Color.appPrimary)
} else {
Text(product.displayPrice)
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(isAnnual ? Color.appTextOnPrimary : Color.appPrimary)
}
}
.padding(18)
.frame(maxWidth: .infinity)
.background(
ZStack {
if isAnnual {
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} else {
Color.appBackgroundSecondary
}
if !isAnnual {
GrainTexture(opacity: 0.01)
}
}
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(isAnnual ? Color.appAccent : Color.appTextSecondary.opacity(0.15), lineWidth: isAnnual ? 2 : 1)
)
.shadow(
color: isAnnual ? Color.appPrimary.opacity(0.3) : Color.black.opacity(colorScheme == .dark ? 0.3 : 0.08),
radius: isAnnual ? 12 : 8,
y: isAnnual ? 6 : 4
)
}
.disabled(isProcessing)
}
}
// MARK: - Organic Card Background
private struct OrganicCardBackground: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
Color.appBackgroundSecondary
GeometryReader { geo in
OrganicBlobShape(variation: 1)
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
Color.appPrimary.opacity(0.01)
],
center: .center,
startRadius: 0,
endRadius: geo.size.width * 0.5
)
)
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.5)
.offset(x: geo.size.width * 0.4, y: -geo.size.height * 0.1)
.blur(radius: 20)
}
GrainTexture(opacity: 0.015)
}
}
}
struct SubscriptionProductButton: View {
let product: Product
let isSelected: Bool
@@ -344,60 +523,21 @@ struct SubscriptionProductButton: View {
}
var body: some View {
Button(action: onSelect) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(product.displayName)
.font(.headline)
.foregroundColor(Color.appTextPrimary)
if let savings = savingsText {
Text(savings)
.font(.caption)
.foregroundColor(Color.appPrimary)
}
}
Spacer()
if isProcessing && isSelected {
ProgressView()
.tint(Color.appTextOnPrimary)
} else {
Text(product.displayPrice)
.font(.title3.weight(.bold))
.foregroundColor(Color.appTextOnPrimary)
}
}
.padding()
.frame(maxWidth: .infinity)
.background(isAnnual ? Color.appPrimary : Color.appSecondary)
.cornerRadius(AppRadius.md)
.overlay(
RoundedRectangle(cornerRadius: AppRadius.md)
.stroke(isAnnual ? Color.appAccent : Color.clear, lineWidth: 2)
)
}
.disabled(isProcessing)
OrganicSubscriptionButton(
product: product,
isSelected: isSelected,
isProcessing: isProcessing,
onSelect: onSelect
)
}
}
struct FeatureRow: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: AppSpacing.md) {
Image(systemName: icon)
.foregroundColor(Color.appPrimary)
.frame(width: 24)
Text(text)
.font(.body)
.foregroundColor(Color.appTextPrimary)
Spacer()
}
OrganicFeatureRow(icon: icon, text: text)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,12 +18,9 @@ struct AllTasksView: View {
@State private var selectedTaskForCancel: TaskResponse?
@State private var showCancelConfirmation = false
// Deep link task ID to open (from push notification)
@State private var pendingTaskId: Int32?
// Column index to scroll to (for deep link navigation)
@State private var scrollToColumnIndex: Int?
// Use ViewModel's computed properties
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
private var hasTasks: Bool { taskViewModel.hasTasks }
@@ -109,12 +106,10 @@ struct AllTasksView: View {
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.taskScreenShown)
// Check for pending navigation from push notification (app launched from notification)
if let taskId = PushNotificationManager.shared.pendingNavigationTaskId {
pendingTaskId = Int32(taskId)
}
// Check if widget completed a task - force refresh if dirty
if WidgetDataManager.shared.areTasksDirty() {
WidgetDataManager.shared.clearDirtyFlag()
loadAllTasks(forceRefresh: true)
@@ -123,43 +118,29 @@ struct AllTasksView: View {
}
residenceViewModel.loadMyResidences()
}
// Handle push notification deep links
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in
print("📬 AllTasksView received .navigateToTask notification")
if let userInfo = notification.userInfo,
let taskId = userInfo["taskId"] as? Int {
print("📬 Setting pendingTaskId to \(taskId)")
pendingTaskId = Int32(taskId)
// If tasks are already loaded, try to navigate immediately
if let response = tasksResponse {
print("📬 Tasks already loaded, attempting immediate navigation")
navigateToTaskInKanban(taskId: Int32(taskId), response: response)
}
} else {
print("📬 Failed to extract taskId from notification userInfo: \(notification.userInfo ?? [:])")
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { notification in
print("📬 AllTasksView received .navigateToEditTask notification")
if let userInfo = notification.userInfo,
let taskId = userInfo["taskId"] as? Int {
print("📬 Setting pendingTaskId to \(taskId)")
pendingTaskId = Int32(taskId)
// If tasks are already loaded, try to navigate immediately
if let response = tasksResponse {
print("📬 Tasks already loaded, attempting immediate navigation")
navigateToTaskInKanban(taskId: Int32(taskId), response: response)
}
}
}
// When tasks load and we have a pending task ID, scroll to column and open the edit sheet
.onChange(of: tasksResponse) { response in
print("📬 tasksResponse changed, pendingTaskId=\(pendingTaskId?.description ?? "nil")")
if let taskId = pendingTaskId, let response = response {
navigateToTaskInKanban(taskId: taskId, response: response)
}
}
// Check dirty flag when app returns from background (widget may have completed a task)
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
if WidgetDataManager.shared.areTasksDirty() {
@@ -173,9 +154,8 @@ struct AllTasksView: View {
@ViewBuilder
private var mainContent: some View {
ZStack {
Color.appBackgroundPrimary
.ignoresSafeArea()
WarmGradientBackground()
if hasNoTasks && isLoadingTasks {
ProgressView()
} else if let error = tasksError {
@@ -184,55 +164,13 @@ struct AllTasksView: View {
}
} else if let tasksResponse = tasksResponse {
if hasNoTasks {
// Empty state with big button
VStack(spacing: 24) {
Spacer()
Image(systemName: "checklist")
.font(.system(size: 64))
.foregroundStyle(Color.appPrimary.opacity(0.6))
Text(L10n.Tasks.noTasksYet)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Tasks.createFirst)
.font(.body)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
Button(action: {
// Check if we should show upgrade prompt before adding
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
showingUpgradePrompt = true
} else {
showAddTask = true
}
}) {
HStack(spacing: 8) {
Image(systemName: "plus")
Text(L10n.Tasks.addButton)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 50)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal, 48)
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
if residenceViewModel.myResidences?.residences.isEmpty ?? true {
Text(L10n.Tasks.addPropertyFirst)
.font(.caption)
.foregroundColor(Color.appError)
}
Spacer()
}
.padding()
OrganicEmptyTasksView(
totalTaskCount: totalTaskCount,
hasResidences: !(residenceViewModel.myResidences?.residences.isEmpty ?? true),
subscriptionCache: subscriptionCache,
showingUpgradePrompt: $showingUpgradePrompt,
showAddTask: $showAddTask
)
} else {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
@@ -277,7 +215,6 @@ struct AllTasksView: View {
}
)
// Show swipe hint on first column when it's empty but others have tasks
if index == 0 && shouldShowSwipeHint {
SwipeHintView()
}
@@ -300,7 +237,6 @@ struct AllTasksView: View {
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(columnIndex, anchor: .leading)
}
// Clear after scrolling
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
scrollToColumnIndex = nil
}
@@ -310,35 +246,33 @@ struct AllTasksView: View {
}
}
}
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Tasks.allTasks)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
// Check if we should show upgrade prompt before adding
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
showingUpgradePrompt = true
} else {
showAddTask = true
HStack(spacing: 12) {
Button(action: {
loadAllTasks(forceRefresh: true)
}) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
.rotationEffect(.degrees(isLoadingTasks ? 360 : 0))
.animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks)
}
}) {
Image(systemName: "plus")
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true || isLoadingTasks)
Button(action: {
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
showingUpgradePrompt = true
} else {
showAddTask = true
}
}) {
OrganicToolbarAddButton()
}
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
}
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
loadAllTasks(forceRefresh: true)
}) {
Image(systemName: "arrow.clockwise")
.rotationEffect(.degrees(isLoadingTasks ? 360 : 0))
.animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks)
}
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true || isLoadingTasks)
}
}
.onChange(of: taskViewModel.isLoading) { isLoading in
@@ -347,7 +281,7 @@ struct AllTasksView: View {
}
}
}
private func loadAllTasks(forceRefresh: Bool = false) {
taskViewModel.loadTasks(forceRefresh: forceRefresh)
}
@@ -357,33 +291,157 @@ struct AllTasksView: View {
}
private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) {
print("📬 navigateToTaskInKanban called with taskId=\(taskId)")
// Find which column contains the task
for (index, column) in response.columns.enumerated() {
if column.tasks.contains(where: { $0.id == taskId }) {
print("📬 Found task in column \(index) '\(column.name)'")
// Clear pending
pendingTaskId = nil
PushNotificationManager.shared.clearPendingNavigation()
// Small delay to ensure view is ready, then scroll
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.scrollToColumnIndex = index
}
return
}
}
// Task not found
print("📬 Task with id=\(taskId) not found")
pendingTaskId = nil
PushNotificationManager.shared.clearPendingNavigation()
}
}
// Extension to apply corner radius to specific corners
// MARK: - Organic Empty Tasks View
private struct OrganicEmptyTasksView: View {
let totalTaskCount: Int
let hasResidences: Bool
let subscriptionCache: SubscriptionCacheWrapper
@Binding var showingUpgradePrompt: Bool
@Binding var showAddTask: Bool
@State private var isAnimating = false
var body: some View {
VStack(spacing: OrganicSpacing.comfortable) {
Spacer()
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 80
)
)
.frame(width: 160, height: 160)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true),
value: isAnimating
)
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 100, height: 100)
Image(systemName: "checklist")
.font(.system(size: 44, weight: .medium))
.foregroundColor(Color.appPrimary)
.offset(y: isAnimating ? -2 : 2)
.animation(
Animation.easeInOut(duration: 2).repeatForever(autoreverses: true),
value: isAnimating
)
}
}
VStack(spacing: 12) {
Text(L10n.Tasks.noTasksYet)
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text(L10n.Tasks.createFirst)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
.padding(.top, 8)
Button(action: {
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
showingUpgradePrompt = true
} else {
showAddTask = true
}
}) {
HStack(spacing: 10) {
Image(systemName: "plus")
.font(.system(size: 16, weight: .bold))
Text(L10n.Tasks.addButton)
.font(.system(size: 17, weight: .semibold))
}
.foregroundColor(Color.appTextOnPrimary)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.85)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: Color.appPrimary.opacity(0.3), radius: 12, y: 6)
}
.disabled(!hasResidences)
.padding(.horizontal, 48)
.padding(.top, 16)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
if !hasResidences {
Text(L10n.Tasks.addPropertyFirst)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appError)
}
Spacer()
HStack(spacing: 40) {
FloatingLeaf(delay: 0, size: 18, color: Color.appPrimary)
FloatingLeaf(delay: 0.5, size: 14, color: Color.appAccent)
FloatingLeaf(delay: 1.0, size: 20, color: Color.appPrimary)
}
.opacity(0.6)
.padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
isAnimating = true
}
}
}
// MARK: - Organic Toolbar Add Button
private struct OrganicToolbarAddButton: View {
var body: some View {
ZStack {
Circle()
.fill(Color.appPrimary)
.frame(width: 32, height: 32)
Image(systemName: "plus")
.font(.system(size: 14, weight: .bold))
.foregroundColor(Color.appTextOnPrimary)
}
}
}
// MARK: - Extensions
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCorner(radius: radius, corners: corners))
@@ -393,7 +451,7 @@ extension View {
struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(
roundedRect: rect,
@@ -411,7 +469,6 @@ struct RoundedCorner: Shape {
}
extension Array where Element == ResidenceResponse {
/// Returns the array as-is (for API compatibility)
func toResidences() -> [ResidenceResponse] {
return self
}

View File

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

View File

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

View File

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

View File

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