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:
@@ -18,7 +18,7 @@ struct ContractorDetailView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackgroundPrimary.ignoresSafeArea()
|
||||
WarmGradientBackground()
|
||||
contentStateView
|
||||
}
|
||||
.onAppear {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ struct FeatureComparisonView: View {
|
||||
.padding(.bottom, AppSpacing.xl)
|
||||
}
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.background(WarmGradientBackground())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user